cortexhub 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cortexhub/__init__.py +143 -0
- cortexhub/adapters/__init__.py +5 -0
- cortexhub/adapters/base.py +131 -0
- cortexhub/adapters/claude_agents.py +322 -0
- cortexhub/adapters/crewai.py +297 -0
- cortexhub/adapters/langgraph.py +386 -0
- cortexhub/adapters/openai_agents.py +192 -0
- cortexhub/audit/__init__.py +25 -0
- cortexhub/audit/events.py +165 -0
- cortexhub/auto_protect.py +128 -0
- cortexhub/backend/__init__.py +5 -0
- cortexhub/backend/client.py +348 -0
- cortexhub/client.py +2149 -0
- cortexhub/config.py +37 -0
- cortexhub/context/__init__.py +5 -0
- cortexhub/context/enricher.py +172 -0
- cortexhub/errors.py +123 -0
- cortexhub/frameworks.py +83 -0
- cortexhub/guardrails/__init__.py +3 -0
- cortexhub/guardrails/injection.py +180 -0
- cortexhub/guardrails/pii.py +378 -0
- cortexhub/guardrails/secrets.py +206 -0
- cortexhub/interceptors/__init__.py +3 -0
- cortexhub/interceptors/llm.py +62 -0
- cortexhub/interceptors/mcp.py +96 -0
- cortexhub/pipeline.py +92 -0
- cortexhub/policy/__init__.py +6 -0
- cortexhub/policy/effects.py +87 -0
- cortexhub/policy/evaluator.py +267 -0
- cortexhub/policy/loader.py +158 -0
- cortexhub/policy/models.py +123 -0
- cortexhub/policy/sync.py +183 -0
- cortexhub/telemetry/__init__.py +40 -0
- cortexhub/telemetry/otel.py +481 -0
- cortexhub/version.py +3 -0
- cortexhub-0.1.0.dist-info/METADATA +275 -0
- cortexhub-0.1.0.dist-info/RECORD +38 -0
- cortexhub-0.1.0.dist-info/WHEEL +4 -0
cortexhub/client.py
ADDED
|
@@ -0,0 +1,2149 @@
|
|
|
1
|
+
"""CortexHub client - main SDK entrypoint.
|
|
2
|
+
|
|
3
|
+
MODE DETERMINATION
|
|
4
|
+
==================
|
|
5
|
+
The SDK mode is determined AUTOMATICALLY by the backend:
|
|
6
|
+
|
|
7
|
+
1. No policies from backend → OBSERVATION MODE
|
|
8
|
+
- Records all agent activity (tool calls, LLM interactions, etc.)
|
|
9
|
+
- Detects PII, secrets, prompt manipulation (logged, not blocked)
|
|
10
|
+
- Sends OpenTelemetry spans to cloud for analysis
|
|
11
|
+
- Compliance teams analyze behavior and create policies
|
|
12
|
+
|
|
13
|
+
2. Policies from backend → ENFORCEMENT MODE
|
|
14
|
+
- Evaluates Cedar policies before execution
|
|
15
|
+
- Can block, redact, or require approvals based on policies
|
|
16
|
+
- Policies are created by compliance team in CortexHub Cloud
|
|
17
|
+
|
|
18
|
+
Architecture:
|
|
19
|
+
- Adapters call execute_governed_tool() - single entrypoint
|
|
20
|
+
- SDK creates OpenTelemetry spans for all activity
|
|
21
|
+
- Spans are sent to cloud via OTLP/HTTP (batched, non-blocking)
|
|
22
|
+
- Policy enforcement only when backend provides policies
|
|
23
|
+
|
|
24
|
+
Privacy Mode:
|
|
25
|
+
- privacy=True (default): Only metadata sent (production-safe)
|
|
26
|
+
- privacy=False: Raw data included (for testing policies in dev/staging)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from typing import TYPE_CHECKING, Any, Callable, Tuple
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from cortexhub.frameworks import Framework
|
|
37
|
+
|
|
38
|
+
import structlog
|
|
39
|
+
from opentelemetry import trace
|
|
40
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
41
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
42
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
43
|
+
from opentelemetry.sdk.resources import Resource as OtelResource
|
|
44
|
+
from opentelemetry.trace import Status, StatusCode
|
|
45
|
+
|
|
46
|
+
from cortexhub.backend.client import BackendClient
|
|
47
|
+
from cortexhub.context.enricher import AgentRegistry, ContextEnricher
|
|
48
|
+
from cortexhub.errors import ApprovalRequiredError, ApprovalDeniedError, PolicyViolationError
|
|
49
|
+
from cortexhub.audit.events import LLMGuardrailFindings
|
|
50
|
+
from cortexhub.guardrails.injection import PromptManipulationDetector
|
|
51
|
+
from cortexhub.guardrails.pii import PIIDetector
|
|
52
|
+
from cortexhub.guardrails.secrets import SecretsDetector
|
|
53
|
+
from cortexhub.interceptors.llm import LLMInterceptor
|
|
54
|
+
from cortexhub.interceptors.mcp import MCPInterceptor
|
|
55
|
+
from cortexhub.policy.effects import Decision, Effect
|
|
56
|
+
from cortexhub.policy.evaluator import PolicyEvaluator
|
|
57
|
+
from cortexhub.policy.models import (
|
|
58
|
+
Action,
|
|
59
|
+
AuthorizationRequest,
|
|
60
|
+
Metadata,
|
|
61
|
+
Principal,
|
|
62
|
+
Resource as PolicyResource,
|
|
63
|
+
RuntimeContext,
|
|
64
|
+
)
|
|
65
|
+
from cortexhub.version import __version__
|
|
66
|
+
|
|
67
|
+
logger = structlog.get_logger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CortexHub:
|
|
71
|
+
"""Main CortexHub SDK client.
|
|
72
|
+
|
|
73
|
+
Privacy-first governance for AI agents using OpenTelemetry.
|
|
74
|
+
|
|
75
|
+
Architecture:
|
|
76
|
+
- Adapters call execute_governed_tool() - single entrypoint
|
|
77
|
+
- SDK creates OpenTelemetry spans for all activity
|
|
78
|
+
- Spans are batched and sent via OTLP/HTTP to backend
|
|
79
|
+
- Policy enforcement only when backend provides policies
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str | None = None,
|
|
85
|
+
api_url: str | None = None,
|
|
86
|
+
agent_id: str | None = None,
|
|
87
|
+
privacy: bool = True,
|
|
88
|
+
):
|
|
89
|
+
"""Initialize CortexHub client.
|
|
90
|
+
|
|
91
|
+
A valid API key is REQUIRED. The SDK will not function without it.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
api_key: CortexHub API key (REQUIRED - get from https://app.cortexhub.ai)
|
|
95
|
+
api_url: CortexHub API URL (optional, defaults to https://api.cortexhub.ai)
|
|
96
|
+
agent_id: Stable agent identifier (e.g., "customer_support_agent")
|
|
97
|
+
privacy: If True (default), only metadata is sent to backend.
|
|
98
|
+
If False, raw inputs/outputs are also sent - useful for
|
|
99
|
+
testing policies, redaction, and approval workflows in
|
|
100
|
+
non-production environments. NEVER set to False in production!
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If no API key is provided
|
|
104
|
+
RuntimeError: If API key validation fails
|
|
105
|
+
"""
|
|
106
|
+
from cortexhub.errors import ConfigurationError
|
|
107
|
+
|
|
108
|
+
# Agent identity - stable across framework adapters
|
|
109
|
+
self.agent_id = agent_id or os.getenv("CORTEXHUB_AGENT_ID") or self._default_agent_id()
|
|
110
|
+
|
|
111
|
+
# Backend connection - API key is REQUIRED
|
|
112
|
+
self.api_key = api_key or os.getenv("CORTEXHUB_API_KEY")
|
|
113
|
+
if not self.api_key:
|
|
114
|
+
raise ConfigurationError(
|
|
115
|
+
"CortexHub API key is required. "
|
|
116
|
+
"Set CORTEXHUB_API_KEY environment variable or pass api_key parameter. "
|
|
117
|
+
"Get your API key from https://app.cortexhub.ai"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self.api_url = self._normalize_api_url(
|
|
121
|
+
api_url or os.getenv("CORTEXHUB_API_URL", "https://api.cortexhub.ai")
|
|
122
|
+
)
|
|
123
|
+
self.api_base_url = f"{self.api_url}/v1"
|
|
124
|
+
|
|
125
|
+
# Privacy mode - controls whether raw data is sent
|
|
126
|
+
# True (default): Only metadata sent (production-safe)
|
|
127
|
+
# False: Raw inputs/outputs sent (for testing policies in dev/staging)
|
|
128
|
+
privacy_env = os.getenv("CORTEXHUB_PRIVACY", "true").lower()
|
|
129
|
+
self.privacy = privacy if privacy is not None else (privacy_env != "false")
|
|
130
|
+
|
|
131
|
+
if not self.privacy:
|
|
132
|
+
logger.warning(
|
|
133
|
+
"⚠️ PRIVACY MODE DISABLED - Raw inputs/outputs will be sent to backend",
|
|
134
|
+
warning="DO NOT USE IN PRODUCTION",
|
|
135
|
+
use_case="Testing policies, redaction, and approval workflows",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Internal state
|
|
139
|
+
self._project_id: str | None = None
|
|
140
|
+
self._sdk_config = None # SDKConfig from backend
|
|
141
|
+
self.session_id = self._generate_session_id()
|
|
142
|
+
|
|
143
|
+
# Initialize OpenTelemetry
|
|
144
|
+
self._tracer_provider = None
|
|
145
|
+
self._tracer = None
|
|
146
|
+
self._init_opentelemetry()
|
|
147
|
+
import atexit
|
|
148
|
+
atexit.register(self.shutdown)
|
|
149
|
+
|
|
150
|
+
# Policy evaluator - set based on backend response
|
|
151
|
+
self.evaluator: PolicyEvaluator | None = None
|
|
152
|
+
self.enforce = False # Will be set to True if policies exist
|
|
153
|
+
|
|
154
|
+
# Validate API key and get configuration (including policies)
|
|
155
|
+
self.backend = BackendClient(self.api_key, self.api_base_url)
|
|
156
|
+
is_valid, sdk_config = self.backend.validate_api_key()
|
|
157
|
+
|
|
158
|
+
if not is_valid:
|
|
159
|
+
raise ConfigurationError(
|
|
160
|
+
"API key validation failed. "
|
|
161
|
+
"Check that your API key is correct and not expired. "
|
|
162
|
+
"Get a new API key from https://app.cortexhub.ai"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._sdk_config = sdk_config
|
|
166
|
+
self._project_id = sdk_config.project_id
|
|
167
|
+
|
|
168
|
+
# Initialize enforcement if backend returned policies
|
|
169
|
+
if sdk_config.has_policies:
|
|
170
|
+
self._init_enforcement_mode(sdk_config)
|
|
171
|
+
else:
|
|
172
|
+
logger.info(
|
|
173
|
+
"No policies configured yet",
|
|
174
|
+
project_id=self._project_id,
|
|
175
|
+
next_step="Create policies in CortexHub dashboard to enable enforcement",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Initialize guardrails with config from backend
|
|
179
|
+
# The guardrail_config specifies:
|
|
180
|
+
# - action: "redact" | "block" | "allow" (what to do when detected)
|
|
181
|
+
# - pii_types/secret_types: which types to detect (None = all)
|
|
182
|
+
# - custom_patterns: additional regex patterns
|
|
183
|
+
pii_allowed_types = None
|
|
184
|
+
secrets_allowed_types = None
|
|
185
|
+
pii_custom_patterns = []
|
|
186
|
+
secrets_custom_patterns = []
|
|
187
|
+
|
|
188
|
+
# Store guardrail actions and scope for use during enforcement
|
|
189
|
+
self._pii_guardrail_action: str | None = None # "redact", "block", or "allow"
|
|
190
|
+
self._secrets_guardrail_action: str | None = None
|
|
191
|
+
self._pii_redaction_scope: str = "both" # "input_only", "output_only", or "both"
|
|
192
|
+
self._secrets_redaction_scope: str = "both"
|
|
193
|
+
|
|
194
|
+
if self._sdk_config and self._sdk_config.policies.guardrail_configs:
|
|
195
|
+
gc = self._sdk_config.policies.guardrail_configs
|
|
196
|
+
|
|
197
|
+
if "pii" in gc:
|
|
198
|
+
pii_config = gc["pii"]
|
|
199
|
+
pii_allowed_types = pii_config.pii_types
|
|
200
|
+
self._pii_guardrail_action = pii_config.action # "redact", "block", or "allow"
|
|
201
|
+
self._pii_redaction_scope = pii_config.redaction_scope or "both"
|
|
202
|
+
if pii_config.custom_patterns:
|
|
203
|
+
from cortexhub.guardrails.pii import CustomPattern as PIICustomPattern
|
|
204
|
+
pii_custom_patterns = [
|
|
205
|
+
PIICustomPattern(
|
|
206
|
+
name=cp.name,
|
|
207
|
+
pattern=cp.pattern,
|
|
208
|
+
description=cp.description,
|
|
209
|
+
enabled=cp.enabled,
|
|
210
|
+
)
|
|
211
|
+
for cp in pii_config.custom_patterns
|
|
212
|
+
]
|
|
213
|
+
logger.info(
|
|
214
|
+
"PII guardrail configured from backend",
|
|
215
|
+
action=self._pii_guardrail_action,
|
|
216
|
+
redaction_scope=self._pii_redaction_scope,
|
|
217
|
+
allowed_types=pii_allowed_types,
|
|
218
|
+
custom_patterns=len(pii_custom_patterns),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if "secrets" in gc:
|
|
222
|
+
secrets_config = gc["secrets"]
|
|
223
|
+
secrets_allowed_types = secrets_config.secret_types
|
|
224
|
+
self._secrets_guardrail_action = secrets_config.action
|
|
225
|
+
self._secrets_redaction_scope = secrets_config.redaction_scope or "both"
|
|
226
|
+
# Note: secrets detector doesn't have custom patterns yet
|
|
227
|
+
logger.info(
|
|
228
|
+
"Secrets guardrail configured from backend",
|
|
229
|
+
action=self._secrets_guardrail_action,
|
|
230
|
+
redaction_scope=self._secrets_redaction_scope,
|
|
231
|
+
allowed_types=secrets_allowed_types,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
self.pii_detector = PIIDetector(
|
|
235
|
+
enabled=True,
|
|
236
|
+
allowed_types=pii_allowed_types,
|
|
237
|
+
custom_patterns=pii_custom_patterns if pii_custom_patterns else None,
|
|
238
|
+
)
|
|
239
|
+
self.secrets_detector = SecretsDetector(enabled=True)
|
|
240
|
+
self.injection_detector = PromptManipulationDetector(enabled=True)
|
|
241
|
+
|
|
242
|
+
# Initialize context enrichment
|
|
243
|
+
self.agent_registry = AgentRegistry()
|
|
244
|
+
self.context_enricher = ContextEnricher(self.agent_registry)
|
|
245
|
+
|
|
246
|
+
# Initialize interceptors
|
|
247
|
+
self.llm_interceptor = LLMInterceptor(self)
|
|
248
|
+
self.mcp_interceptor = MCPInterceptor(self)
|
|
249
|
+
|
|
250
|
+
mode_str = "enforcement" if self.enforce else "observation"
|
|
251
|
+
privacy_str = "enabled (metadata only)" if self.privacy else "DISABLED (raw data sent)"
|
|
252
|
+
logger.info(
|
|
253
|
+
"CortexHub initialized",
|
|
254
|
+
agent_id=self.agent_id,
|
|
255
|
+
session_id=self.session_id,
|
|
256
|
+
mode=mode_str,
|
|
257
|
+
privacy=privacy_str,
|
|
258
|
+
telemetry="OpenTelemetry (OTLP/HTTP)",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _init_opentelemetry(self) -> None:
|
|
262
|
+
"""Initialize OpenTelemetry tracer and exporter."""
|
|
263
|
+
# Create resource with agent metadata
|
|
264
|
+
resource = OtelResource.create({
|
|
265
|
+
"service.name": "cortexhub-sdk",
|
|
266
|
+
"service.version": __version__,
|
|
267
|
+
"cortexhub.agent.id": self.agent_id,
|
|
268
|
+
"cortexhub.privacy.mode": "enabled" if self.privacy else "disabled",
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
# Create tracer provider
|
|
272
|
+
self._tracer_provider = TracerProvider(resource=resource)
|
|
273
|
+
|
|
274
|
+
# Configure OTLP exporter if we have API key
|
|
275
|
+
if self.api_key and self.api_url:
|
|
276
|
+
base_url = self.api_url.rstrip("/")
|
|
277
|
+
exporter = OTLPSpanExporter(
|
|
278
|
+
endpoint=f"{base_url}/v1/traces",
|
|
279
|
+
headers={"X-API-Key": self.api_key},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Add batch processor (handles batching, retry, backpressure)
|
|
283
|
+
processor = BatchSpanProcessor(
|
|
284
|
+
exporter,
|
|
285
|
+
max_queue_size=2048, # Max spans in queue
|
|
286
|
+
max_export_batch_size=1, # Export each span quickly
|
|
287
|
+
schedule_delay_millis=250, # Near real-time export
|
|
288
|
+
export_timeout_millis=30000, # 30 second timeout
|
|
289
|
+
)
|
|
290
|
+
self._tracer_provider.add_span_processor(processor)
|
|
291
|
+
|
|
292
|
+
# Set as global tracer provider
|
|
293
|
+
trace.set_tracer_provider(self._tracer_provider)
|
|
294
|
+
|
|
295
|
+
# Create tracer
|
|
296
|
+
self._tracer = trace.get_tracer("cortexhub", __version__)
|
|
297
|
+
|
|
298
|
+
logger.debug(
|
|
299
|
+
"OpenTelemetry initialized",
|
|
300
|
+
exporter="OTLP/HTTP" if self.api_key else "none",
|
|
301
|
+
resource_attributes={
|
|
302
|
+
"service.name": "cortexhub-sdk",
|
|
303
|
+
"cortexhub.agent.id": self.agent_id,
|
|
304
|
+
"cortexhub.privacy.mode": "enabled" if self.privacy else "disabled",
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _generate_session_id(self) -> str:
|
|
309
|
+
"""Generate a unique session ID."""
|
|
310
|
+
import uuid
|
|
311
|
+
from datetime import datetime
|
|
312
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
313
|
+
return f"{timestamp}-{str(uuid.uuid4())[:8]}"
|
|
314
|
+
|
|
315
|
+
def _normalize_api_url(self, url: str) -> str:
|
|
316
|
+
base_url = url.rstrip("/")
|
|
317
|
+
if base_url.endswith("/api/v1"):
|
|
318
|
+
base_url = base_url[: -len("/api/v1")]
|
|
319
|
+
if base_url.endswith("/v1"):
|
|
320
|
+
base_url = base_url[: -len("/v1")]
|
|
321
|
+
return base_url
|
|
322
|
+
|
|
323
|
+
def _init_enforcement_mode(self, sdk_config) -> None:
|
|
324
|
+
"""Initialize enforcement mode with policies from backend."""
|
|
325
|
+
from cortexhub.policy.loader import PolicyBundle
|
|
326
|
+
from cortexhub.errors import PolicyLoadError
|
|
327
|
+
|
|
328
|
+
# Create policy bundle from backend response
|
|
329
|
+
policy_bundle = PolicyBundle(
|
|
330
|
+
policies=sdk_config.policies.policies,
|
|
331
|
+
schema=sdk_config.policies.schema or {},
|
|
332
|
+
metadata={
|
|
333
|
+
"version": sdk_config.policies.version,
|
|
334
|
+
"default_behavior": "allow_and_log",
|
|
335
|
+
"policy_map": sdk_config.policies.policy_metadata or {},
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
self.evaluator = PolicyEvaluator(policy_bundle)
|
|
341
|
+
self.enforce = True
|
|
342
|
+
except PolicyLoadError:
|
|
343
|
+
self.enforce = False
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
logger.info(
|
|
347
|
+
"Enforcement mode (policies from backend)",
|
|
348
|
+
project_id=self._project_id,
|
|
349
|
+
policy_version=sdk_config.policies.version,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _default_agent_id(self) -> str:
|
|
353
|
+
"""Generate a default agent ID based on hostname."""
|
|
354
|
+
import socket
|
|
355
|
+
hostname = socket.gethostname()
|
|
356
|
+
return f"agent.{hostname}"
|
|
357
|
+
|
|
358
|
+
# =========================================================================
|
|
359
|
+
# REQUEST BUILDER - Structured, not flattened
|
|
360
|
+
# =========================================================================
|
|
361
|
+
|
|
362
|
+
def build_request(
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
principal: Principal | dict[str, str] | None = None,
|
|
366
|
+
action: Action | dict[str, str],
|
|
367
|
+
resource: PolicyResource | dict[str, str],
|
|
368
|
+
args: dict[str, Any],
|
|
369
|
+
framework: str,
|
|
370
|
+
) -> AuthorizationRequest:
|
|
371
|
+
"""Build a properly structured AuthorizationRequest.
|
|
372
|
+
|
|
373
|
+
This is the ONLY way to build requests. No flattening.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
principal: Who is making the request (defaults to self.agent_id)
|
|
377
|
+
action: What action is being performed
|
|
378
|
+
resource: What resource is being accessed
|
|
379
|
+
args: Arguments to the action
|
|
380
|
+
framework: Framework name (langchain, openai_agents, etc.)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Properly structured AuthorizationRequest
|
|
384
|
+
"""
|
|
385
|
+
# Principal - use agent_id if not specified
|
|
386
|
+
if principal is None:
|
|
387
|
+
principal = Principal(type="Agent", id=self.agent_id)
|
|
388
|
+
elif isinstance(principal, dict):
|
|
389
|
+
principal = Principal(**principal)
|
|
390
|
+
|
|
391
|
+
# Action
|
|
392
|
+
if isinstance(action, dict):
|
|
393
|
+
action = Action(**action)
|
|
394
|
+
|
|
395
|
+
# Resource
|
|
396
|
+
if isinstance(resource, dict):
|
|
397
|
+
resource = PolicyResource(**resource)
|
|
398
|
+
|
|
399
|
+
# Build context
|
|
400
|
+
metadata = Metadata(session_id=self.session_id)
|
|
401
|
+
runtime = RuntimeContext(framework=framework)
|
|
402
|
+
|
|
403
|
+
context = {
|
|
404
|
+
"args": args,
|
|
405
|
+
"runtime": runtime.model_dump(),
|
|
406
|
+
"metadata": metadata.model_dump(),
|
|
407
|
+
"guardrails": {
|
|
408
|
+
"pii_detected": False,
|
|
409
|
+
"pii_types": [],
|
|
410
|
+
"secrets_detected": False,
|
|
411
|
+
"secrets_types": [],
|
|
412
|
+
"prompt_manipulation": False,
|
|
413
|
+
},
|
|
414
|
+
"redaction": {"applied": False},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return AuthorizationRequest(
|
|
418
|
+
principal=principal,
|
|
419
|
+
action=action,
|
|
420
|
+
resource=resource,
|
|
421
|
+
context=context,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# =========================================================================
|
|
425
|
+
# SINGLE ENTRYPOINT FOR ADAPTERS
|
|
426
|
+
# =========================================================================
|
|
427
|
+
|
|
428
|
+
def execute_governed_tool(
|
|
429
|
+
self,
|
|
430
|
+
*,
|
|
431
|
+
tool_name: str,
|
|
432
|
+
args: dict[str, Any],
|
|
433
|
+
framework: str,
|
|
434
|
+
call_original: Callable[[], Any],
|
|
435
|
+
tool_description: str | None = None,
|
|
436
|
+
principal: Principal | dict[str, str] | None = None,
|
|
437
|
+
resource_type: str = "Tool",
|
|
438
|
+
) -> Any:
|
|
439
|
+
"""Execute a tool with full governance pipeline.
|
|
440
|
+
|
|
441
|
+
This is the SINGLE entrypoint for all adapters.
|
|
442
|
+
Adapters should not implement governance logic themselves.
|
|
443
|
+
|
|
444
|
+
NOTE: Guardrails do NOT apply to tools - tools NEED sensitive data to work.
|
|
445
|
+
Guardrails only apply to LLM calls where sensitive data should NOT flow.
|
|
446
|
+
|
|
447
|
+
Pipeline:
|
|
448
|
+
1. Build AuthorizationRequest (structured, not flattened)
|
|
449
|
+
2. Create tool.invoke span with metadata
|
|
450
|
+
3. Evaluate policy -> get Decision (enforcement mode only)
|
|
451
|
+
4. Add policy decision to span if enforcement mode
|
|
452
|
+
5. Branch on Decision (ALLOW/DENY/ESCALATE)
|
|
453
|
+
6. Execute tool
|
|
454
|
+
7. Record result on span and end span
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
tool_name: Name of the tool being invoked
|
|
458
|
+
args: Arguments to the tool
|
|
459
|
+
framework: Framework name (langchain, openai_agents, etc.)
|
|
460
|
+
call_original: Callable that executes the original tool
|
|
461
|
+
tool_description: Human-readable description of what the tool does
|
|
462
|
+
principal: Optional principal override
|
|
463
|
+
resource_type: Type of resource (default: "Tool")
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Result of tool execution
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
PolicyViolationError: If policy denies
|
|
470
|
+
ApprovalDeniedError: If approval denied
|
|
471
|
+
"""
|
|
472
|
+
# Step 1: Build request (structured, NOT flattened)
|
|
473
|
+
policy_args = self._sanitize_policy_args(args)
|
|
474
|
+
request = self.build_request(
|
|
475
|
+
principal=principal,
|
|
476
|
+
action={"type": "tool.invoke", "name": tool_name},
|
|
477
|
+
resource=PolicyResource(type=resource_type, id=tool_name),
|
|
478
|
+
args=policy_args,
|
|
479
|
+
framework=framework,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Step 2: Extract arg names only (not values, not classifications)
|
|
483
|
+
arg_names = list(policy_args.keys()) if isinstance(policy_args, dict) else []
|
|
484
|
+
|
|
485
|
+
# Step 3: Create tool.invoke span
|
|
486
|
+
with self._tracer.start_as_current_span(
|
|
487
|
+
name="tool.invoke",
|
|
488
|
+
kind=trace.SpanKind.INTERNAL,
|
|
489
|
+
) as span:
|
|
490
|
+
# Set standard attributes
|
|
491
|
+
span.set_attribute("cortexhub.session.id", self.session_id)
|
|
492
|
+
span.set_attribute("cortexhub.agent.id", self.agent_id)
|
|
493
|
+
span.set_attribute("cortexhub.tool.name", tool_name)
|
|
494
|
+
span.set_attribute("cortexhub.tool.framework", framework)
|
|
495
|
+
|
|
496
|
+
if tool_description:
|
|
497
|
+
span.set_attribute("cortexhub.tool.description", tool_description)
|
|
498
|
+
|
|
499
|
+
if arg_names:
|
|
500
|
+
span.set_attribute("cortexhub.tool.arg_names", arg_names)
|
|
501
|
+
|
|
502
|
+
if isinstance(policy_args, dict) and policy_args:
|
|
503
|
+
arg_schema = self._infer_arg_schema(policy_args)
|
|
504
|
+
if arg_schema:
|
|
505
|
+
span.set_attribute("cortexhub.tool.arg_schema", json.dumps(arg_schema))
|
|
506
|
+
|
|
507
|
+
# Raw data only if privacy disabled
|
|
508
|
+
if not self.privacy and args:
|
|
509
|
+
span.set_attribute("cortexhub.raw.args", json.dumps(args, default=str))
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
# Step 4: Policy evaluation (enforcement mode only)
|
|
513
|
+
if self.enforce and self.evaluator:
|
|
514
|
+
if os.getenv("CORTEXHUB_DEBUG_POLICY", "").lower() in ("1", "true", "yes"):
|
|
515
|
+
logger.info(
|
|
516
|
+
"Policy evaluation request",
|
|
517
|
+
tool=tool_name,
|
|
518
|
+
args=self._summarize_policy_args(policy_args),
|
|
519
|
+
privacy=self.privacy,
|
|
520
|
+
)
|
|
521
|
+
# ENFORCEMENT MODE: Evaluate policies
|
|
522
|
+
decision = self.evaluator.evaluate(request)
|
|
523
|
+
|
|
524
|
+
# Add policy decision event to span
|
|
525
|
+
span.add_event(
|
|
526
|
+
"policy.decision",
|
|
527
|
+
attributes={
|
|
528
|
+
"decision.effect": decision.effect.value,
|
|
529
|
+
"decision.policy_id": decision.policy_id or "",
|
|
530
|
+
"decision.reasoning": decision.reasoning,
|
|
531
|
+
"decision.policy_name": decision.policy_name or "",
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Branch on Decision
|
|
536
|
+
if decision.effect == Effect.DENY:
|
|
537
|
+
span.set_status(Status(StatusCode.ERROR, decision.reasoning))
|
|
538
|
+
policy_label = decision.reasoning
|
|
539
|
+
if decision.policy_id:
|
|
540
|
+
name_segment = (
|
|
541
|
+
f"{decision.policy_name}" if decision.policy_name else "Unknown policy"
|
|
542
|
+
)
|
|
543
|
+
policy_label = f"{decision.reasoning} (Policy: {name_segment}, ID: {decision.policy_id})"
|
|
544
|
+
raise PolicyViolationError(
|
|
545
|
+
policy_label,
|
|
546
|
+
policy_id=decision.policy_id,
|
|
547
|
+
reasoning=decision.reasoning,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if decision.effect == Effect.ESCALATE:
|
|
551
|
+
context_hash = self._compute_context_hash(tool_name, policy_args)
|
|
552
|
+
try:
|
|
553
|
+
approval_response = self.backend.create_approval(
|
|
554
|
+
run_id=self.session_id,
|
|
555
|
+
trace_id=self._get_current_trace_id(),
|
|
556
|
+
tool_name=tool_name,
|
|
557
|
+
tool_args_values=args if not self.privacy else None,
|
|
558
|
+
context_hash=context_hash,
|
|
559
|
+
policy_id=decision.policy_id or "",
|
|
560
|
+
policy_name=decision.policy_name or "Unknown Policy",
|
|
561
|
+
policy_explanation=decision.reasoning,
|
|
562
|
+
risk_category=self._infer_risk_category(decision, tool_name),
|
|
563
|
+
agent_id=self.agent_id,
|
|
564
|
+
framework=framework,
|
|
565
|
+
environment=os.getenv("CORTEXHUB_ENVIRONMENT"),
|
|
566
|
+
)
|
|
567
|
+
except Exception as e:
|
|
568
|
+
logger.error("Failed to create approval", error=str(e))
|
|
569
|
+
raise PolicyViolationError(
|
|
570
|
+
f"Tool '{tool_name}' requires approval but failed to create approval record: {e}",
|
|
571
|
+
policy_id=decision.policy_id,
|
|
572
|
+
reasoning=decision.reasoning,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
span.add_event(
|
|
576
|
+
"approval.created",
|
|
577
|
+
attributes={
|
|
578
|
+
"approval_id": approval_response.get("approval_id", ""),
|
|
579
|
+
"tool_name": tool_name,
|
|
580
|
+
"policy_id": decision.policy_id or "",
|
|
581
|
+
"expires_at": approval_response.get("expires_at", ""),
|
|
582
|
+
},
|
|
583
|
+
)
|
|
584
|
+
span.set_status(Status(StatusCode.ERROR, "Approval required"))
|
|
585
|
+
|
|
586
|
+
raise ApprovalRequiredError(
|
|
587
|
+
f"Tool '{tool_name}' requires approval: {decision.reasoning}",
|
|
588
|
+
approval_id=approval_response.get("approval_id", ""),
|
|
589
|
+
run_id=self.session_id,
|
|
590
|
+
tool_name=tool_name,
|
|
591
|
+
policy_id=decision.policy_id,
|
|
592
|
+
policy_name=decision.policy_name,
|
|
593
|
+
reason=decision.reasoning,
|
|
594
|
+
expires_at=approval_response.get("expires_at"),
|
|
595
|
+
decision_endpoint=approval_response.get("decision_endpoint"),
|
|
596
|
+
)
|
|
597
|
+
# else: OBSERVATION MODE - no policy evaluation
|
|
598
|
+
# Just observe the tool invocation
|
|
599
|
+
|
|
600
|
+
# Step 5: Execute tool
|
|
601
|
+
exec_start = time.perf_counter()
|
|
602
|
+
result = call_original()
|
|
603
|
+
exec_latency_ms = (time.perf_counter() - exec_start) * 1000
|
|
604
|
+
|
|
605
|
+
# Step 6: Record success result on span
|
|
606
|
+
span.set_attribute("cortexhub.result.success", True)
|
|
607
|
+
span.set_status(Status(StatusCode.OK))
|
|
608
|
+
|
|
609
|
+
# Raw result only if privacy disabled
|
|
610
|
+
if not self.privacy and result is not None:
|
|
611
|
+
span.set_attribute("cortexhub.raw.result", json.dumps(result, default=str))
|
|
612
|
+
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
except (PolicyViolationError, ApprovalRequiredError, ApprovalDeniedError):
|
|
616
|
+
# These are expected governance failures - re-raise
|
|
617
|
+
raise
|
|
618
|
+
except Exception as e:
|
|
619
|
+
# Unexpected error during execution
|
|
620
|
+
span.set_attribute("cortexhub.result.success", False)
|
|
621
|
+
span.set_attribute("cortexhub.error.message", str(e))
|
|
622
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
623
|
+
raise
|
|
624
|
+
|
|
625
|
+
async def execute_governed_tool_async(
|
|
626
|
+
self,
|
|
627
|
+
*,
|
|
628
|
+
tool_name: str,
|
|
629
|
+
args: dict[str, Any],
|
|
630
|
+
framework: str,
|
|
631
|
+
call_original: Callable[[], Any],
|
|
632
|
+
tool_description: str | None = None,
|
|
633
|
+
principal: Principal | dict[str, str] | None = None,
|
|
634
|
+
resource_type: str = "Tool",
|
|
635
|
+
) -> Any:
|
|
636
|
+
"""Async version of execute_governed_tool.
|
|
637
|
+
|
|
638
|
+
Same pipeline as sync version, but awaits the tool execution.
|
|
639
|
+
NOTE: Guardrails do NOT apply to tools - tools NEED sensitive data.
|
|
640
|
+
"""
|
|
641
|
+
# Step 1: Build request
|
|
642
|
+
policy_args = self._sanitize_policy_args(args)
|
|
643
|
+
request = self.build_request(
|
|
644
|
+
principal=principal,
|
|
645
|
+
action={"type": "tool.invoke", "name": tool_name},
|
|
646
|
+
resource=PolicyResource(type=resource_type, id=tool_name),
|
|
647
|
+
args=policy_args,
|
|
648
|
+
framework=framework,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Step 2: Extract arg names only (not values, not classifications)
|
|
652
|
+
arg_names = list(policy_args.keys()) if isinstance(policy_args, dict) else []
|
|
653
|
+
|
|
654
|
+
# Step 3: Create tool.invoke span
|
|
655
|
+
with self._tracer.start_as_current_span(
|
|
656
|
+
name="tool.invoke",
|
|
657
|
+
kind=trace.SpanKind.INTERNAL,
|
|
658
|
+
) as span:
|
|
659
|
+
# Set standard attributes
|
|
660
|
+
span.set_attribute("cortexhub.session.id", self.session_id)
|
|
661
|
+
span.set_attribute("cortexhub.agent.id", self.agent_id)
|
|
662
|
+
span.set_attribute("cortexhub.tool.name", tool_name)
|
|
663
|
+
span.set_attribute("cortexhub.tool.framework", framework)
|
|
664
|
+
|
|
665
|
+
if tool_description:
|
|
666
|
+
span.set_attribute("cortexhub.tool.description", tool_description)
|
|
667
|
+
|
|
668
|
+
if arg_names:
|
|
669
|
+
span.set_attribute("cortexhub.tool.arg_names", arg_names)
|
|
670
|
+
|
|
671
|
+
if isinstance(policy_args, dict) and policy_args:
|
|
672
|
+
arg_schema = self._infer_arg_schema(policy_args)
|
|
673
|
+
if arg_schema:
|
|
674
|
+
span.set_attribute("cortexhub.tool.arg_schema", json.dumps(arg_schema))
|
|
675
|
+
|
|
676
|
+
# Raw data only if privacy disabled
|
|
677
|
+
if not self.privacy and args:
|
|
678
|
+
span.set_attribute("cortexhub.raw.args", json.dumps(args, default=str))
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
# Step 4: Policy evaluation (enforcement mode only)
|
|
682
|
+
if self.enforce and self.evaluator:
|
|
683
|
+
decision = self.evaluator.evaluate(request)
|
|
684
|
+
|
|
685
|
+
# Add policy decision event to span
|
|
686
|
+
span.add_event(
|
|
687
|
+
"policy.decision",
|
|
688
|
+
attributes={
|
|
689
|
+
"decision.effect": decision.effect.value,
|
|
690
|
+
"decision.policy_id": decision.policy_id or "",
|
|
691
|
+
"decision.reasoning": decision.reasoning,
|
|
692
|
+
"decision.policy_name": decision.policy_name or "",
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
if decision.effect == Effect.DENY:
|
|
697
|
+
span.set_status(Status(StatusCode.ERROR, decision.reasoning))
|
|
698
|
+
policy_label = decision.reasoning
|
|
699
|
+
if decision.policy_id:
|
|
700
|
+
name_segment = (
|
|
701
|
+
f"{decision.policy_name}" if decision.policy_name else "Unknown policy"
|
|
702
|
+
)
|
|
703
|
+
policy_label = f"{decision.reasoning} (Policy: {name_segment}, ID: {decision.policy_id})"
|
|
704
|
+
raise PolicyViolationError(
|
|
705
|
+
policy_label,
|
|
706
|
+
policy_id=decision.policy_id,
|
|
707
|
+
reasoning=decision.reasoning,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if decision.effect == Effect.ESCALATE:
|
|
711
|
+
context_hash = self._compute_context_hash(tool_name, policy_args)
|
|
712
|
+
try:
|
|
713
|
+
approval_response = self.backend.create_approval(
|
|
714
|
+
run_id=self.session_id,
|
|
715
|
+
trace_id=self._get_current_trace_id(),
|
|
716
|
+
tool_name=tool_name,
|
|
717
|
+
tool_args_values=args if not self.privacy else None,
|
|
718
|
+
context_hash=context_hash,
|
|
719
|
+
policy_id=decision.policy_id or "",
|
|
720
|
+
policy_name=decision.policy_name or "Unknown Policy",
|
|
721
|
+
policy_explanation=decision.reasoning,
|
|
722
|
+
risk_category=self._infer_risk_category(decision, tool_name),
|
|
723
|
+
agent_id=self.agent_id,
|
|
724
|
+
framework=framework,
|
|
725
|
+
environment=os.getenv("CORTEXHUB_ENVIRONMENT"),
|
|
726
|
+
)
|
|
727
|
+
except Exception as e:
|
|
728
|
+
logger.error("Failed to create approval", error=str(e))
|
|
729
|
+
raise PolicyViolationError(
|
|
730
|
+
f"Tool '{tool_name}' requires approval but failed to create approval record: {e}",
|
|
731
|
+
policy_id=decision.policy_id,
|
|
732
|
+
reasoning=decision.reasoning,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
span.add_event(
|
|
736
|
+
"approval.created",
|
|
737
|
+
attributes={
|
|
738
|
+
"approval_id": approval_response.get("approval_id", ""),
|
|
739
|
+
"tool_name": tool_name,
|
|
740
|
+
"policy_id": decision.policy_id or "",
|
|
741
|
+
"expires_at": approval_response.get("expires_at", ""),
|
|
742
|
+
},
|
|
743
|
+
)
|
|
744
|
+
span.set_status(Status(StatusCode.ERROR, "Approval required"))
|
|
745
|
+
|
|
746
|
+
raise ApprovalRequiredError(
|
|
747
|
+
f"Tool '{tool_name}' requires approval: {decision.reasoning}",
|
|
748
|
+
approval_id=approval_response.get("approval_id", ""),
|
|
749
|
+
run_id=self.session_id,
|
|
750
|
+
tool_name=tool_name,
|
|
751
|
+
policy_id=decision.policy_id,
|
|
752
|
+
policy_name=decision.policy_name,
|
|
753
|
+
reason=decision.reasoning,
|
|
754
|
+
expires_at=approval_response.get("expires_at"),
|
|
755
|
+
decision_endpoint=approval_response.get("decision_endpoint"),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Step 5: Execute tool (async)
|
|
759
|
+
exec_start = time.perf_counter()
|
|
760
|
+
result = await call_original()
|
|
761
|
+
exec_latency_ms = (time.perf_counter() - exec_start) * 1000
|
|
762
|
+
|
|
763
|
+
# Step 6: Record success result on span
|
|
764
|
+
span.set_attribute("cortexhub.result.success", True)
|
|
765
|
+
span.set_status(Status(StatusCode.OK))
|
|
766
|
+
span.set_attribute("cortexhub.result.latency_ms", exec_latency_ms)
|
|
767
|
+
|
|
768
|
+
if not self.privacy and result is not None:
|
|
769
|
+
span.set_attribute("cortexhub.raw.result", json.dumps(result, default=str))
|
|
770
|
+
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
except (PolicyViolationError, ApprovalRequiredError, ApprovalDeniedError):
|
|
774
|
+
raise
|
|
775
|
+
except Exception as e:
|
|
776
|
+
span.set_attribute("cortexhub.result.success", False)
|
|
777
|
+
span.set_attribute("cortexhub.error.message", str(e))
|
|
778
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
779
|
+
raise
|
|
780
|
+
|
|
781
|
+
def execute_governed_llm_call(
|
|
782
|
+
self,
|
|
783
|
+
*,
|
|
784
|
+
model: str,
|
|
785
|
+
prompt: Any,
|
|
786
|
+
framework: str,
|
|
787
|
+
call_original: Callable[[Any], Any],
|
|
788
|
+
) -> Any:
|
|
789
|
+
"""Execute an LLM call with governance enforcement and guardrails."""
|
|
790
|
+
guardrail_findings = LLMGuardrailFindings()
|
|
791
|
+
prompt_text = self._extract_llm_prompt_text(prompt)
|
|
792
|
+
|
|
793
|
+
if prompt_text:
|
|
794
|
+
# Use detect() for better summary statistics
|
|
795
|
+
pii_result = self.pii_detector.detect({"prompt": prompt_text})
|
|
796
|
+
if pii_result.detected:
|
|
797
|
+
guardrail_findings.pii_in_prompt = {
|
|
798
|
+
"detected": True,
|
|
799
|
+
"count": pii_result.count, # Total occurrences
|
|
800
|
+
"unique_count": pii_result.unique_count, # Unique values
|
|
801
|
+
"types": pii_result.types,
|
|
802
|
+
"counts_per_type": pii_result.counts_per_type,
|
|
803
|
+
"unique_per_type": pii_result.unique_values_per_type,
|
|
804
|
+
"summary": pii_result.summary, # Human-readable: "2 SSN, 3 EMAIL"
|
|
805
|
+
"findings": [
|
|
806
|
+
{"type": f.get("type"), "score": f.get("confidence", 0.0)}
|
|
807
|
+
for f in pii_result.findings[:10]
|
|
808
|
+
],
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
secrets_result = self.secrets_detector.detect({"prompt": prompt_text})
|
|
812
|
+
if secrets_result.detected:
|
|
813
|
+
guardrail_findings.secrets_in_prompt = {
|
|
814
|
+
"detected": True,
|
|
815
|
+
"count": secrets_result.count,
|
|
816
|
+
"types": secrets_result.types,
|
|
817
|
+
"counts_per_type": secrets_result.counts_per_type,
|
|
818
|
+
"findings": [{"type": f.get("type")} for f in secrets_result.findings[:10]],
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
injection_result = self.injection_detector.detect({"prompt": prompt_text})
|
|
822
|
+
if injection_result.detected:
|
|
823
|
+
guardrail_findings.prompt_manipulation = {
|
|
824
|
+
"detected": True,
|
|
825
|
+
"count": injection_result.count,
|
|
826
|
+
"patterns": injection_result.patterns,
|
|
827
|
+
"findings": injection_result.findings[:10],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
decision = None
|
|
831
|
+
redaction_applied = False
|
|
832
|
+
redaction_summary: dict[str, Any] | None = None
|
|
833
|
+
prompt_to_send = prompt
|
|
834
|
+
|
|
835
|
+
if self.enforce and self.evaluator:
|
|
836
|
+
guardrails_context = {
|
|
837
|
+
"pii_detected": guardrail_findings.pii_in_prompt.get("detected", False),
|
|
838
|
+
"pii_types": guardrail_findings.pii_in_prompt.get("types", []),
|
|
839
|
+
"secrets_detected": guardrail_findings.secrets_in_prompt.get("detected", False),
|
|
840
|
+
"secrets_types": guardrail_findings.secrets_in_prompt.get("types", []),
|
|
841
|
+
"prompt_manipulation": guardrail_findings.prompt_manipulation.get("detected", False),
|
|
842
|
+
}
|
|
843
|
+
request = self.build_request(
|
|
844
|
+
action={"type": "llm.call", "name": model},
|
|
845
|
+
resource=PolicyResource(type="LLM", id=model),
|
|
846
|
+
args={"model": model},
|
|
847
|
+
framework=framework,
|
|
848
|
+
).with_enriched_context(
|
|
849
|
+
guardrails=guardrails_context,
|
|
850
|
+
redaction={"applied": False},
|
|
851
|
+
)
|
|
852
|
+
decision = self.evaluator.evaluate(request)
|
|
853
|
+
|
|
854
|
+
# If DENY and PII/secrets detected, attempt redaction based on guardrail action and scope
|
|
855
|
+
if decision.effect == Effect.DENY and (
|
|
856
|
+
guardrails_context["pii_detected"] or guardrails_context["secrets_detected"]
|
|
857
|
+
):
|
|
858
|
+
# Check if guardrail action allows redaction (vs hard block) for INPUT
|
|
859
|
+
pii_wants_redact_input = (
|
|
860
|
+
guardrails_context["pii_detected"] and
|
|
861
|
+
self._pii_guardrail_action == "redact" and
|
|
862
|
+
self._pii_redaction_scope in ("both", "input_only")
|
|
863
|
+
)
|
|
864
|
+
secrets_wants_redact_input = (
|
|
865
|
+
guardrails_context["secrets_detected"] and
|
|
866
|
+
self._secrets_guardrail_action == "redact" and
|
|
867
|
+
self._secrets_redaction_scope in ("both", "input_only")
|
|
868
|
+
)
|
|
869
|
+
should_attempt_redaction = pii_wants_redact_input or secrets_wants_redact_input
|
|
870
|
+
|
|
871
|
+
if should_attempt_redaction:
|
|
872
|
+
redacted_prompt, redaction_summary = self.redact_llm_prompt_for_enforcement(
|
|
873
|
+
prompt
|
|
874
|
+
)
|
|
875
|
+
if redaction_summary.get("applied"):
|
|
876
|
+
redaction_applied = True
|
|
877
|
+
prompt_to_send = redacted_prompt
|
|
878
|
+
request = request.with_enriched_context(
|
|
879
|
+
guardrails=guardrails_context,
|
|
880
|
+
redaction={"applied": True},
|
|
881
|
+
)
|
|
882
|
+
# Re-evaluate with redacted content
|
|
883
|
+
re_evaluated = self.evaluator.evaluate(request)
|
|
884
|
+
# If now allowed, mark as REDACT (sensitive data was removed before LLM call)
|
|
885
|
+
if re_evaluated.effect == Effect.ALLOW:
|
|
886
|
+
decision = Decision.redact(
|
|
887
|
+
reasoning=f"PII/secrets redacted before LLM call. Original: {decision.reasoning}",
|
|
888
|
+
policy_id=decision.policy_id,
|
|
889
|
+
policy_name=decision.policy_name,
|
|
890
|
+
)
|
|
891
|
+
else:
|
|
892
|
+
decision = re_evaluated
|
|
893
|
+
|
|
894
|
+
if decision.effect == Effect.DENY:
|
|
895
|
+
policy_label = decision.reasoning
|
|
896
|
+
if decision.policy_id:
|
|
897
|
+
name_segment = (
|
|
898
|
+
f"{decision.policy_name}" if decision.policy_name else "Unknown policy"
|
|
899
|
+
)
|
|
900
|
+
policy_label = (
|
|
901
|
+
f"{decision.reasoning} (Policy: {name_segment}, ID: {decision.policy_id})"
|
|
902
|
+
)
|
|
903
|
+
raise PolicyViolationError(
|
|
904
|
+
policy_label,
|
|
905
|
+
policy_id=decision.policy_id,
|
|
906
|
+
reasoning=decision.reasoning,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
if decision.effect == Effect.ESCALATE:
|
|
910
|
+
llm_args = {"model": model}
|
|
911
|
+
context_hash = self._compute_context_hash("llm.call", llm_args)
|
|
912
|
+
try:
|
|
913
|
+
approval_response = self.backend.create_approval(
|
|
914
|
+
run_id=self.session_id,
|
|
915
|
+
trace_id=self._get_current_trace_id(),
|
|
916
|
+
tool_name="llm.call",
|
|
917
|
+
tool_args_values=llm_args if not self.privacy else None,
|
|
918
|
+
context_hash=context_hash,
|
|
919
|
+
policy_id=decision.policy_id or "",
|
|
920
|
+
policy_name=decision.policy_name or "Unknown Policy",
|
|
921
|
+
policy_explanation=decision.reasoning,
|
|
922
|
+
risk_category=self._infer_risk_category(decision, "llm.call"),
|
|
923
|
+
agent_id=self.agent_id,
|
|
924
|
+
framework=framework,
|
|
925
|
+
environment=os.getenv("CORTEXHUB_ENVIRONMENT"),
|
|
926
|
+
)
|
|
927
|
+
except Exception as e:
|
|
928
|
+
logger.error("Failed to create approval", error=str(e))
|
|
929
|
+
raise PolicyViolationError(
|
|
930
|
+
f"LLM call to '{model}' requires approval but failed to create approval record: {e}",
|
|
931
|
+
policy_id=decision.policy_id,
|
|
932
|
+
reasoning=decision.reasoning,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
raise ApprovalRequiredError(
|
|
936
|
+
f"LLM call to '{model}' requires approval: {decision.reasoning}",
|
|
937
|
+
approval_id=approval_response.get("approval_id", ""),
|
|
938
|
+
run_id=self.session_id,
|
|
939
|
+
tool_name="llm.call",
|
|
940
|
+
policy_id=decision.policy_id,
|
|
941
|
+
policy_name=decision.policy_name,
|
|
942
|
+
reason=decision.reasoning,
|
|
943
|
+
expires_at=approval_response.get("expires_at"),
|
|
944
|
+
decision_endpoint=approval_response.get("decision_endpoint"),
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
response = call_original(prompt_to_send)
|
|
948
|
+
response_text = self._extract_llm_response_text(response)
|
|
949
|
+
response_redaction_applied = False
|
|
950
|
+
response_redaction_summary: dict[str, Any] | None = None
|
|
951
|
+
|
|
952
|
+
if response_text:
|
|
953
|
+
pii_result = self.pii_detector.detect({"response": response_text})
|
|
954
|
+
if pii_result.detected:
|
|
955
|
+
guardrail_findings.pii_in_response = {
|
|
956
|
+
"detected": True,
|
|
957
|
+
"count": pii_result.count,
|
|
958
|
+
"unique_count": pii_result.unique_count,
|
|
959
|
+
"types": pii_result.types,
|
|
960
|
+
"counts_per_type": pii_result.counts_per_type,
|
|
961
|
+
"unique_per_type": pii_result.unique_values_per_type,
|
|
962
|
+
"summary": pii_result.summary,
|
|
963
|
+
"findings": [
|
|
964
|
+
{"type": f.get("type"), "score": f.get("confidence", 0.0)}
|
|
965
|
+
for f in pii_result.findings[:10]
|
|
966
|
+
],
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
# Redact response if scope includes output
|
|
970
|
+
pii_wants_redact_output = (
|
|
971
|
+
self._pii_guardrail_action == "redact" and
|
|
972
|
+
self._pii_redaction_scope in ("both", "output_only")
|
|
973
|
+
)
|
|
974
|
+
if pii_wants_redact_output:
|
|
975
|
+
response, response_redaction_summary = self._redact_llm_response(response)
|
|
976
|
+
if response_redaction_summary and response_redaction_summary.get("applied"):
|
|
977
|
+
response_redaction_applied = True
|
|
978
|
+
response_text = self._extract_llm_response_text(response)
|
|
979
|
+
|
|
980
|
+
# Merge redaction summaries for logging
|
|
981
|
+
final_redaction_summary = redaction_summary
|
|
982
|
+
if response_redaction_applied and response_redaction_summary:
|
|
983
|
+
if final_redaction_summary:
|
|
984
|
+
final_redaction_summary = {
|
|
985
|
+
**final_redaction_summary,
|
|
986
|
+
"response_redacted": True,
|
|
987
|
+
"response_pii_types": response_redaction_summary.get("pii_types", []),
|
|
988
|
+
}
|
|
989
|
+
else:
|
|
990
|
+
final_redaction_summary = {
|
|
991
|
+
"applied": True,
|
|
992
|
+
"response_redacted": True,
|
|
993
|
+
"response_pii_types": response_redaction_summary.get("pii_types", []),
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
self.log_llm_call(
|
|
997
|
+
trace_id=str(uuid.uuid4()),
|
|
998
|
+
model=model,
|
|
999
|
+
prompt=prompt_to_send,
|
|
1000
|
+
response=response_text,
|
|
1001
|
+
guardrail_findings=guardrail_findings,
|
|
1002
|
+
agent_id=self.agent_id,
|
|
1003
|
+
decision=decision,
|
|
1004
|
+
redaction_applied=redaction_applied or response_redaction_applied,
|
|
1005
|
+
redaction_summary=final_redaction_summary,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
return response
|
|
1009
|
+
|
|
1010
|
+
async def execute_governed_llm_call_async(
|
|
1011
|
+
self,
|
|
1012
|
+
*,
|
|
1013
|
+
model: str,
|
|
1014
|
+
prompt: Any,
|
|
1015
|
+
framework: str,
|
|
1016
|
+
call_original: Callable[[Any], Any],
|
|
1017
|
+
) -> Any:
|
|
1018
|
+
"""Async LLM governance wrapper."""
|
|
1019
|
+
guardrail_findings = LLMGuardrailFindings()
|
|
1020
|
+
prompt_text = self._extract_llm_prompt_text(prompt)
|
|
1021
|
+
|
|
1022
|
+
if prompt_text:
|
|
1023
|
+
# Use detect() for better summary statistics
|
|
1024
|
+
pii_result = self.pii_detector.detect({"prompt": prompt_text})
|
|
1025
|
+
if pii_result.detected:
|
|
1026
|
+
guardrail_findings.pii_in_prompt = {
|
|
1027
|
+
"detected": True,
|
|
1028
|
+
"count": pii_result.count,
|
|
1029
|
+
"unique_count": pii_result.unique_count,
|
|
1030
|
+
"types": pii_result.types,
|
|
1031
|
+
"counts_per_type": pii_result.counts_per_type,
|
|
1032
|
+
"unique_per_type": pii_result.unique_values_per_type,
|
|
1033
|
+
"summary": pii_result.summary,
|
|
1034
|
+
"findings": [
|
|
1035
|
+
{"type": f.get("type"), "score": f.get("confidence", 0.0)}
|
|
1036
|
+
for f in pii_result.findings[:10]
|
|
1037
|
+
],
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
secrets_result = self.secrets_detector.detect({"prompt": prompt_text})
|
|
1041
|
+
if secrets_result.detected:
|
|
1042
|
+
guardrail_findings.secrets_in_prompt = {
|
|
1043
|
+
"detected": True,
|
|
1044
|
+
"count": secrets_result.count,
|
|
1045
|
+
"types": secrets_result.types,
|
|
1046
|
+
"counts_per_type": secrets_result.counts_per_type,
|
|
1047
|
+
"findings": [{"type": f.get("type")} for f in secrets_result.findings[:10]],
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
injection_result = self.injection_detector.detect({"prompt": prompt_text})
|
|
1051
|
+
if injection_result.detected:
|
|
1052
|
+
guardrail_findings.prompt_manipulation = {
|
|
1053
|
+
"detected": True,
|
|
1054
|
+
"count": injection_result.count,
|
|
1055
|
+
"patterns": injection_result.patterns,
|
|
1056
|
+
"findings": injection_result.findings[:10],
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
decision = None
|
|
1060
|
+
redaction_applied = False
|
|
1061
|
+
redaction_summary: dict[str, Any] | None = None
|
|
1062
|
+
prompt_to_send = prompt
|
|
1063
|
+
|
|
1064
|
+
if self.enforce and self.evaluator:
|
|
1065
|
+
guardrails_context = {
|
|
1066
|
+
"pii_detected": guardrail_findings.pii_in_prompt.get("detected", False),
|
|
1067
|
+
"pii_types": guardrail_findings.pii_in_prompt.get("types", []),
|
|
1068
|
+
"secrets_detected": guardrail_findings.secrets_in_prompt.get("detected", False),
|
|
1069
|
+
"secrets_types": guardrail_findings.secrets_in_prompt.get("types", []),
|
|
1070
|
+
"prompt_manipulation": guardrail_findings.prompt_manipulation.get("detected", False),
|
|
1071
|
+
}
|
|
1072
|
+
request = self.build_request(
|
|
1073
|
+
action={"type": "llm.call", "name": model},
|
|
1074
|
+
resource=PolicyResource(type="LLM", id=model),
|
|
1075
|
+
args={"model": model},
|
|
1076
|
+
framework=framework,
|
|
1077
|
+
).with_enriched_context(
|
|
1078
|
+
guardrails=guardrails_context,
|
|
1079
|
+
redaction={"applied": False},
|
|
1080
|
+
)
|
|
1081
|
+
decision = self.evaluator.evaluate(request)
|
|
1082
|
+
|
|
1083
|
+
# If DENY and PII/secrets detected, attempt redaction based on guardrail action and scope
|
|
1084
|
+
if decision.effect == Effect.DENY and (
|
|
1085
|
+
guardrails_context["pii_detected"] or guardrails_context["secrets_detected"]
|
|
1086
|
+
):
|
|
1087
|
+
# Check if guardrail action allows redaction (vs hard block) for INPUT
|
|
1088
|
+
pii_wants_redact_input = (
|
|
1089
|
+
guardrails_context["pii_detected"] and
|
|
1090
|
+
self._pii_guardrail_action == "redact" and
|
|
1091
|
+
self._pii_redaction_scope in ("both", "input_only")
|
|
1092
|
+
)
|
|
1093
|
+
secrets_wants_redact_input = (
|
|
1094
|
+
guardrails_context["secrets_detected"] and
|
|
1095
|
+
self._secrets_guardrail_action == "redact" and
|
|
1096
|
+
self._secrets_redaction_scope in ("both", "input_only")
|
|
1097
|
+
)
|
|
1098
|
+
should_attempt_redaction = pii_wants_redact_input or secrets_wants_redact_input
|
|
1099
|
+
|
|
1100
|
+
if should_attempt_redaction:
|
|
1101
|
+
redacted_prompt, redaction_summary = self.redact_llm_prompt_for_enforcement(
|
|
1102
|
+
prompt
|
|
1103
|
+
)
|
|
1104
|
+
if redaction_summary.get("applied"):
|
|
1105
|
+
redaction_applied = True
|
|
1106
|
+
prompt_to_send = redacted_prompt
|
|
1107
|
+
request = request.with_enriched_context(
|
|
1108
|
+
guardrails=guardrails_context,
|
|
1109
|
+
redaction={"applied": True},
|
|
1110
|
+
)
|
|
1111
|
+
# Re-evaluate with redacted content (async version)
|
|
1112
|
+
re_evaluated = self.evaluator.evaluate(request)
|
|
1113
|
+
# If now allowed, mark as REDACT (sensitive data was removed before LLM call)
|
|
1114
|
+
if re_evaluated.effect == Effect.ALLOW:
|
|
1115
|
+
decision = Decision.redact(
|
|
1116
|
+
reasoning=f"PII/secrets redacted before LLM call. Original: {decision.reasoning}",
|
|
1117
|
+
policy_id=decision.policy_id,
|
|
1118
|
+
policy_name=decision.policy_name,
|
|
1119
|
+
)
|
|
1120
|
+
else:
|
|
1121
|
+
decision = re_evaluated
|
|
1122
|
+
|
|
1123
|
+
if decision.effect == Effect.DENY:
|
|
1124
|
+
policy_label = decision.reasoning
|
|
1125
|
+
if decision.policy_id:
|
|
1126
|
+
name_segment = (
|
|
1127
|
+
f"{decision.policy_name}" if decision.policy_name else "Unknown policy"
|
|
1128
|
+
)
|
|
1129
|
+
policy_label = (
|
|
1130
|
+
f"{decision.reasoning} (Policy: {name_segment}, ID: {decision.policy_id})"
|
|
1131
|
+
)
|
|
1132
|
+
raise PolicyViolationError(
|
|
1133
|
+
policy_label,
|
|
1134
|
+
policy_id=decision.policy_id,
|
|
1135
|
+
reasoning=decision.reasoning,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
if decision.effect == Effect.ESCALATE:
|
|
1139
|
+
llm_args = {"model": model}
|
|
1140
|
+
context_hash = self._compute_context_hash("llm.call", llm_args)
|
|
1141
|
+
try:
|
|
1142
|
+
approval_response = self.backend.create_approval(
|
|
1143
|
+
run_id=self.session_id,
|
|
1144
|
+
trace_id=self._get_current_trace_id(),
|
|
1145
|
+
tool_name="llm.call",
|
|
1146
|
+
tool_args_values=llm_args if not self.privacy else None,
|
|
1147
|
+
context_hash=context_hash,
|
|
1148
|
+
policy_id=decision.policy_id or "",
|
|
1149
|
+
policy_name=decision.policy_name or "Unknown Policy",
|
|
1150
|
+
policy_explanation=decision.reasoning,
|
|
1151
|
+
risk_category=self._infer_risk_category(decision, "llm.call"),
|
|
1152
|
+
agent_id=self.agent_id,
|
|
1153
|
+
framework=framework,
|
|
1154
|
+
environment=os.getenv("CORTEXHUB_ENVIRONMENT"),
|
|
1155
|
+
)
|
|
1156
|
+
except Exception as e:
|
|
1157
|
+
logger.error("Failed to create approval", error=str(e))
|
|
1158
|
+
raise PolicyViolationError(
|
|
1159
|
+
f"LLM call to '{model}' requires approval but failed to create approval record: {e}",
|
|
1160
|
+
policy_id=decision.policy_id,
|
|
1161
|
+
reasoning=decision.reasoning,
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
raise ApprovalRequiredError(
|
|
1165
|
+
f"LLM call to '{model}' requires approval: {decision.reasoning}",
|
|
1166
|
+
approval_id=approval_response.get("approval_id", ""),
|
|
1167
|
+
run_id=self.session_id,
|
|
1168
|
+
tool_name="llm.call",
|
|
1169
|
+
policy_id=decision.policy_id,
|
|
1170
|
+
policy_name=decision.policy_name,
|
|
1171
|
+
reason=decision.reasoning,
|
|
1172
|
+
expires_at=approval_response.get("expires_at"),
|
|
1173
|
+
decision_endpoint=approval_response.get("decision_endpoint"),
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
response = await call_original(prompt_to_send)
|
|
1177
|
+
response_text = self._extract_llm_response_text(response)
|
|
1178
|
+
response_redaction_applied = False
|
|
1179
|
+
response_redaction_summary: dict[str, Any] | None = None
|
|
1180
|
+
|
|
1181
|
+
if response_text:
|
|
1182
|
+
pii_result = self.pii_detector.detect({"response": response_text})
|
|
1183
|
+
if pii_result.detected:
|
|
1184
|
+
guardrail_findings.pii_in_response = {
|
|
1185
|
+
"detected": True,
|
|
1186
|
+
"count": pii_result.count,
|
|
1187
|
+
"unique_count": pii_result.unique_count,
|
|
1188
|
+
"types": pii_result.types,
|
|
1189
|
+
"counts_per_type": pii_result.counts_per_type,
|
|
1190
|
+
"unique_per_type": pii_result.unique_values_per_type,
|
|
1191
|
+
"summary": pii_result.summary,
|
|
1192
|
+
"findings": [
|
|
1193
|
+
{"type": f.get("type"), "score": f.get("confidence", 0.0)}
|
|
1194
|
+
for f in pii_result.findings[:10]
|
|
1195
|
+
],
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
# Redact response if scope includes output
|
|
1199
|
+
pii_wants_redact_output = (
|
|
1200
|
+
self._pii_guardrail_action == "redact" and
|
|
1201
|
+
self._pii_redaction_scope in ("both", "output_only")
|
|
1202
|
+
)
|
|
1203
|
+
if pii_wants_redact_output:
|
|
1204
|
+
response, response_redaction_summary = self._redact_llm_response(response)
|
|
1205
|
+
if response_redaction_summary and response_redaction_summary.get("applied"):
|
|
1206
|
+
response_redaction_applied = True
|
|
1207
|
+
response_text = self._extract_llm_response_text(response)
|
|
1208
|
+
|
|
1209
|
+
# Merge redaction summaries for logging
|
|
1210
|
+
final_redaction_summary = redaction_summary
|
|
1211
|
+
if response_redaction_applied and response_redaction_summary:
|
|
1212
|
+
if final_redaction_summary:
|
|
1213
|
+
final_redaction_summary = {
|
|
1214
|
+
**final_redaction_summary,
|
|
1215
|
+
"response_redacted": True,
|
|
1216
|
+
"response_pii_types": response_redaction_summary.get("pii_types", []),
|
|
1217
|
+
}
|
|
1218
|
+
else:
|
|
1219
|
+
final_redaction_summary = {
|
|
1220
|
+
"applied": True,
|
|
1221
|
+
"response_redacted": True,
|
|
1222
|
+
"response_pii_types": response_redaction_summary.get("pii_types", []),
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
self.log_llm_call(
|
|
1226
|
+
trace_id=str(uuid.uuid4()),
|
|
1227
|
+
model=model,
|
|
1228
|
+
prompt=prompt_to_send,
|
|
1229
|
+
response=response_text,
|
|
1230
|
+
guardrail_findings=guardrail_findings,
|
|
1231
|
+
agent_id=self.agent_id,
|
|
1232
|
+
decision=decision,
|
|
1233
|
+
redaction_applied=redaction_applied or response_redaction_applied,
|
|
1234
|
+
redaction_summary=final_redaction_summary,
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
return response
|
|
1238
|
+
|
|
1239
|
+
# =========================================================================
|
|
1240
|
+
# SPAN CREATION METHODS
|
|
1241
|
+
# =========================================================================
|
|
1242
|
+
|
|
1243
|
+
def trace_tool_call(
|
|
1244
|
+
self,
|
|
1245
|
+
tool_name: str,
|
|
1246
|
+
tool_description: str | None = None,
|
|
1247
|
+
arg_names: list[str] | None = None,
|
|
1248
|
+
args: dict[str, Any] | None = None,
|
|
1249
|
+
) -> trace.Span:
|
|
1250
|
+
"""Start a span for a tool call.
|
|
1251
|
+
|
|
1252
|
+
Usage:
|
|
1253
|
+
with cortex.trace_tool_call("process_refund", args={"amount": 75}) as span:
|
|
1254
|
+
result = tool.invoke(args)
|
|
1255
|
+
span.set_attribute("cortexhub.result.success", True)
|
|
1256
|
+
"""
|
|
1257
|
+
span = self._tracer.start_span(
|
|
1258
|
+
name="tool.invoke",
|
|
1259
|
+
kind=trace.SpanKind.INTERNAL,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
# Set standard attributes
|
|
1263
|
+
span.set_attribute("cortexhub.session.id", self.session_id)
|
|
1264
|
+
span.set_attribute("cortexhub.agent.id", self.agent_id)
|
|
1265
|
+
span.set_attribute("cortexhub.tool.name", tool_name)
|
|
1266
|
+
|
|
1267
|
+
if tool_description:
|
|
1268
|
+
span.set_attribute("cortexhub.tool.description", tool_description)
|
|
1269
|
+
|
|
1270
|
+
if arg_names:
|
|
1271
|
+
span.set_attribute("cortexhub.tool.arg_names", arg_names)
|
|
1272
|
+
|
|
1273
|
+
if args:
|
|
1274
|
+
arg_schema = self._infer_arg_schema(args)
|
|
1275
|
+
if arg_schema:
|
|
1276
|
+
span.set_attribute("cortexhub.tool.arg_schema", json.dumps(arg_schema))
|
|
1277
|
+
|
|
1278
|
+
# Raw data only if privacy disabled
|
|
1279
|
+
if not self.privacy and args:
|
|
1280
|
+
span.set_attribute("cortexhub.raw.args", json.dumps(args, default=str))
|
|
1281
|
+
|
|
1282
|
+
return span
|
|
1283
|
+
|
|
1284
|
+
def _infer_arg_schema(self, args: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1285
|
+
"""Infer argument schema types without sending values."""
|
|
1286
|
+
if not isinstance(args, dict):
|
|
1287
|
+
return []
|
|
1288
|
+
schema: list[dict[str, Any]] = []
|
|
1289
|
+
for name, value in args.items():
|
|
1290
|
+
schema.append(
|
|
1291
|
+
{
|
|
1292
|
+
"name": name,
|
|
1293
|
+
"type": self._infer_value_type(value),
|
|
1294
|
+
"classification": "safe",
|
|
1295
|
+
"is_redacted": False,
|
|
1296
|
+
}
|
|
1297
|
+
)
|
|
1298
|
+
return schema
|
|
1299
|
+
|
|
1300
|
+
def _infer_value_type(self, value: Any) -> str:
|
|
1301
|
+
if isinstance(value, bool):
|
|
1302
|
+
return "boolean"
|
|
1303
|
+
if isinstance(value, (int, float)):
|
|
1304
|
+
return "number"
|
|
1305
|
+
if isinstance(value, list):
|
|
1306
|
+
return "array"
|
|
1307
|
+
if isinstance(value, dict):
|
|
1308
|
+
return "object"
|
|
1309
|
+
if isinstance(value, str):
|
|
1310
|
+
return "string"
|
|
1311
|
+
return "unknown"
|
|
1312
|
+
|
|
1313
|
+
def trace_llm_call(
|
|
1314
|
+
self,
|
|
1315
|
+
model: str,
|
|
1316
|
+
prompt: str | None = None,
|
|
1317
|
+
) -> trace.Span:
|
|
1318
|
+
"""Start a span for an LLM call.
|
|
1319
|
+
|
|
1320
|
+
Usage:
|
|
1321
|
+
with cortex.trace_llm_call("gpt-4o-mini", prompt=messages) as span:
|
|
1322
|
+
response = llm.invoke(messages)
|
|
1323
|
+
span.set_attribute("gen_ai.usage.completion_tokens", response.usage.completion_tokens)
|
|
1324
|
+
"""
|
|
1325
|
+
span = self._tracer.start_span(
|
|
1326
|
+
name="llm.call",
|
|
1327
|
+
kind=trace.SpanKind.CLIENT,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
# Set standard attributes (following gen_ai.* conventions)
|
|
1331
|
+
span.set_attribute("cortexhub.session.id", self.session_id)
|
|
1332
|
+
span.set_attribute("cortexhub.agent.id", self.agent_id)
|
|
1333
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
1334
|
+
|
|
1335
|
+
# Run guardrails on prompt
|
|
1336
|
+
if prompt:
|
|
1337
|
+
self._check_guardrails(span, prompt, "prompt")
|
|
1338
|
+
|
|
1339
|
+
# Raw data only if privacy disabled
|
|
1340
|
+
if not self.privacy:
|
|
1341
|
+
span.set_attribute("cortexhub.raw.prompt", prompt)
|
|
1342
|
+
|
|
1343
|
+
return span
|
|
1344
|
+
|
|
1345
|
+
def _check_guardrails(
|
|
1346
|
+
self,
|
|
1347
|
+
span: trace.Span,
|
|
1348
|
+
content: str,
|
|
1349
|
+
content_type: str, # "prompt" or "response"
|
|
1350
|
+
) -> None:
|
|
1351
|
+
"""Run guardrail checks and add span events for findings."""
|
|
1352
|
+
|
|
1353
|
+
# PII detection
|
|
1354
|
+
pii_result = self.pii_detector.detect(content)
|
|
1355
|
+
if pii_result.detected:
|
|
1356
|
+
attributes = {
|
|
1357
|
+
"pii.detected": True,
|
|
1358
|
+
"pii.unique_count": pii_result.unique_count, # Unique PII values (meaningful number)
|
|
1359
|
+
"pii.total_occurrences": pii_result.count, # Total matches (for detailed analysis)
|
|
1360
|
+
"pii.types": pii_result.types,
|
|
1361
|
+
"pii.summary": pii_result.summary, # Human-readable: "2 SSN, 3 EMAIL"
|
|
1362
|
+
}
|
|
1363
|
+
# Add counts per type for backend aggregation
|
|
1364
|
+
if pii_result.counts_per_type:
|
|
1365
|
+
attributes["pii.counts_per_type"] = json.dumps(pii_result.counts_per_type)
|
|
1366
|
+
if pii_result.unique_values_per_type:
|
|
1367
|
+
attributes["pii.unique_per_type"] = json.dumps(pii_result.unique_values_per_type)
|
|
1368
|
+
span.add_event(f"guardrail.pii_in_{content_type}", attributes=attributes)
|
|
1369
|
+
|
|
1370
|
+
# Secrets detection
|
|
1371
|
+
secrets_result = self.secrets_detector.detect(content)
|
|
1372
|
+
if secrets_result.detected:
|
|
1373
|
+
attributes = {
|
|
1374
|
+
"secrets.detected": True,
|
|
1375
|
+
"secrets.count": secrets_result.count,
|
|
1376
|
+
"secrets.types": secrets_result.types,
|
|
1377
|
+
}
|
|
1378
|
+
# Add counts per type for backend aggregation
|
|
1379
|
+
if secrets_result.counts_per_type:
|
|
1380
|
+
attributes["secrets.counts_per_type"] = json.dumps(secrets_result.counts_per_type)
|
|
1381
|
+
span.add_event(f"guardrail.secrets_in_{content_type}", attributes=attributes)
|
|
1382
|
+
|
|
1383
|
+
# Prompt manipulation detection (only for prompts)
|
|
1384
|
+
if content_type == "prompt":
|
|
1385
|
+
manipulation_result = self.injection_detector.detect(content)
|
|
1386
|
+
if manipulation_result.detected:
|
|
1387
|
+
span.add_event(
|
|
1388
|
+
"guardrail.prompt_manipulation",
|
|
1389
|
+
attributes={
|
|
1390
|
+
"manipulation.detected": True,
|
|
1391
|
+
"manipulation.patterns": manipulation_result.patterns,
|
|
1392
|
+
}
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
def _serialize_raw_value(self, value: Any) -> str | None:
|
|
1396
|
+
"""Serialize raw values for span attributes."""
|
|
1397
|
+
if value is None:
|
|
1398
|
+
return None
|
|
1399
|
+
if isinstance(value, str):
|
|
1400
|
+
return value
|
|
1401
|
+
return json.dumps(value, default=str)
|
|
1402
|
+
|
|
1403
|
+
def _redact_for_display(self, value: Any) -> Any:
|
|
1404
|
+
"""Redact sensitive data for display without altering execution.
|
|
1405
|
+
|
|
1406
|
+
This keeps object structure intact and only redacts string values,
|
|
1407
|
+
so tool arguments/outputs remain readable.
|
|
1408
|
+
"""
|
|
1409
|
+
if value is None:
|
|
1410
|
+
return None
|
|
1411
|
+
if isinstance(value, str):
|
|
1412
|
+
redacted, _ = self.pii_detector.redact(value)
|
|
1413
|
+
redacted, _ = self.secrets_detector.redact(redacted)
|
|
1414
|
+
return redacted
|
|
1415
|
+
if isinstance(value, dict):
|
|
1416
|
+
return {key: self._redact_for_display(val) for key, val in value.items()}
|
|
1417
|
+
if isinstance(value, list):
|
|
1418
|
+
return [self._redact_for_display(item) for item in value]
|
|
1419
|
+
return value
|
|
1420
|
+
|
|
1421
|
+
def _redact_llm_prompt_preview(self, prompt: Any) -> Any:
|
|
1422
|
+
"""Redact LLM prompt for preview without touching tool arguments.
|
|
1423
|
+
|
|
1424
|
+
- Redacts message content fields
|
|
1425
|
+
- Leaves tool call arguments unchanged
|
|
1426
|
+
"""
|
|
1427
|
+
if prompt is None:
|
|
1428
|
+
return None
|
|
1429
|
+
if isinstance(prompt, list):
|
|
1430
|
+
return [self._redact_llm_prompt_preview(item) for item in prompt]
|
|
1431
|
+
if isinstance(prompt, dict):
|
|
1432
|
+
redacted: dict[str, Any] = {}
|
|
1433
|
+
for key, value in prompt.items():
|
|
1434
|
+
if key == "content":
|
|
1435
|
+
redacted[key] = self._redact_for_display(value)
|
|
1436
|
+
continue
|
|
1437
|
+
if key == "tool_calls" and isinstance(value, list):
|
|
1438
|
+
redacted_calls = []
|
|
1439
|
+
for call in value:
|
|
1440
|
+
if not isinstance(call, dict):
|
|
1441
|
+
redacted_calls.append(call)
|
|
1442
|
+
continue
|
|
1443
|
+
call_copy = dict(call)
|
|
1444
|
+
function = call_copy.get("function")
|
|
1445
|
+
if isinstance(function, dict):
|
|
1446
|
+
function_copy = dict(function)
|
|
1447
|
+
if "arguments" in function_copy:
|
|
1448
|
+
function_copy["arguments"] = function_copy["arguments"]
|
|
1449
|
+
call_copy["function"] = function_copy
|
|
1450
|
+
redacted_calls.append(call_copy)
|
|
1451
|
+
redacted[key] = redacted_calls
|
|
1452
|
+
continue
|
|
1453
|
+
redacted[key] = self._redact_llm_prompt_preview(value)
|
|
1454
|
+
return redacted
|
|
1455
|
+
return self._redact_for_display(prompt)
|
|
1456
|
+
|
|
1457
|
+
def redact_llm_prompt_for_enforcement(
|
|
1458
|
+
self,
|
|
1459
|
+
prompt: Any,
|
|
1460
|
+
) -> Tuple[Any, dict[str, Any]]:
|
|
1461
|
+
"""Redact PII and secrets from LLM prompt content.
|
|
1462
|
+
|
|
1463
|
+
Only message content is redacted; tool call arguments remain untouched.
|
|
1464
|
+
"""
|
|
1465
|
+
import copy
|
|
1466
|
+
|
|
1467
|
+
summary = {
|
|
1468
|
+
"pii_redacted": False,
|
|
1469
|
+
"pii_types": [],
|
|
1470
|
+
"secrets_redacted": False,
|
|
1471
|
+
"secrets_types": [],
|
|
1472
|
+
"applied": False,
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if isinstance(prompt, list):
|
|
1476
|
+
redacted_messages = copy.deepcopy(prompt)
|
|
1477
|
+
for message in redacted_messages:
|
|
1478
|
+
if isinstance(message, dict):
|
|
1479
|
+
content = message.get("content")
|
|
1480
|
+
if not isinstance(content, str) or not content:
|
|
1481
|
+
continue
|
|
1482
|
+
elif hasattr(message, "content") and isinstance(message.content, str):
|
|
1483
|
+
content = message.content
|
|
1484
|
+
else:
|
|
1485
|
+
continue
|
|
1486
|
+
|
|
1487
|
+
content_after = content
|
|
1488
|
+
secrets_redacted, secret_findings = self.secrets_detector.redact(content_after)
|
|
1489
|
+
if secret_findings:
|
|
1490
|
+
summary["secrets_redacted"] = True
|
|
1491
|
+
summary["secrets_types"].extend(
|
|
1492
|
+
list({f.get("type", "unknown") for f in secret_findings})
|
|
1493
|
+
)
|
|
1494
|
+
if isinstance(secrets_redacted, str):
|
|
1495
|
+
content_after = secrets_redacted
|
|
1496
|
+
|
|
1497
|
+
pii_redacted, pii_findings = self.pii_detector.redact(content_after)
|
|
1498
|
+
if pii_findings:
|
|
1499
|
+
summary["pii_redacted"] = True
|
|
1500
|
+
summary["pii_types"].extend(
|
|
1501
|
+
list({f.get("type", "unknown") for f in pii_findings})
|
|
1502
|
+
)
|
|
1503
|
+
if isinstance(pii_redacted, str):
|
|
1504
|
+
content_after = pii_redacted
|
|
1505
|
+
|
|
1506
|
+
if content_after != content:
|
|
1507
|
+
if isinstance(message, dict):
|
|
1508
|
+
message["content"] = content_after
|
|
1509
|
+
else:
|
|
1510
|
+
message.content = content_after
|
|
1511
|
+
summary["applied"] = True
|
|
1512
|
+
|
|
1513
|
+
summary["pii_types"] = list(set(summary["pii_types"]))
|
|
1514
|
+
summary["secrets_types"] = list(set(summary["secrets_types"]))
|
|
1515
|
+
return redacted_messages, summary
|
|
1516
|
+
|
|
1517
|
+
if isinstance(prompt, str):
|
|
1518
|
+
content_after = prompt
|
|
1519
|
+
secrets_redacted, secret_findings = self.secrets_detector.redact(content_after)
|
|
1520
|
+
if secret_findings:
|
|
1521
|
+
summary["secrets_redacted"] = True
|
|
1522
|
+
summary["secrets_types"] = list({f.get("type", "unknown") for f in secret_findings})
|
|
1523
|
+
if isinstance(secrets_redacted, str):
|
|
1524
|
+
content_after = secrets_redacted
|
|
1525
|
+
|
|
1526
|
+
pii_redacted, pii_findings = self.pii_detector.redact(content_after)
|
|
1527
|
+
if pii_findings:
|
|
1528
|
+
summary["pii_redacted"] = True
|
|
1529
|
+
summary["pii_types"] = list({f.get("type", "unknown") for f in pii_findings})
|
|
1530
|
+
if isinstance(pii_redacted, str):
|
|
1531
|
+
content_after = pii_redacted
|
|
1532
|
+
|
|
1533
|
+
summary["applied"] = content_after != prompt
|
|
1534
|
+
return content_after, summary
|
|
1535
|
+
|
|
1536
|
+
return prompt, summary
|
|
1537
|
+
|
|
1538
|
+
def _redact_llm_response(
|
|
1539
|
+
self,
|
|
1540
|
+
response: Any,
|
|
1541
|
+
) -> Tuple[Any, dict[str, Any]]:
|
|
1542
|
+
"""Redact PII and secrets from LLM response content.
|
|
1543
|
+
|
|
1544
|
+
Modifies the response in place to redact sensitive data before
|
|
1545
|
+
passing to downstream systems.
|
|
1546
|
+
"""
|
|
1547
|
+
import copy
|
|
1548
|
+
|
|
1549
|
+
summary = {
|
|
1550
|
+
"pii_redacted": False,
|
|
1551
|
+
"pii_types": [],
|
|
1552
|
+
"secrets_redacted": False,
|
|
1553
|
+
"secrets_types": [],
|
|
1554
|
+
"applied": False,
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
# Handle string response
|
|
1558
|
+
if isinstance(response, str):
|
|
1559
|
+
content_after = response
|
|
1560
|
+
secrets_redacted, secret_findings = self.secrets_detector.redact(content_after)
|
|
1561
|
+
if secret_findings:
|
|
1562
|
+
summary["secrets_redacted"] = True
|
|
1563
|
+
summary["secrets_types"] = list({f.get("type", "unknown") for f in secret_findings})
|
|
1564
|
+
if isinstance(secrets_redacted, str):
|
|
1565
|
+
content_after = secrets_redacted
|
|
1566
|
+
|
|
1567
|
+
pii_redacted, pii_findings = self.pii_detector.redact(content_after)
|
|
1568
|
+
if pii_findings:
|
|
1569
|
+
summary["pii_redacted"] = True
|
|
1570
|
+
summary["pii_types"] = list({f.get("type", "unknown") for f in pii_findings})
|
|
1571
|
+
if isinstance(pii_redacted, str):
|
|
1572
|
+
content_after = pii_redacted
|
|
1573
|
+
|
|
1574
|
+
summary["applied"] = content_after != response
|
|
1575
|
+
return content_after, summary
|
|
1576
|
+
|
|
1577
|
+
# Handle dict response (common format: {"content": "...", "role": "assistant"})
|
|
1578
|
+
if isinstance(response, dict):
|
|
1579
|
+
redacted = copy.deepcopy(response)
|
|
1580
|
+
content = redacted.get("content")
|
|
1581
|
+
if isinstance(content, str):
|
|
1582
|
+
content_after = content
|
|
1583
|
+
secrets_redacted, secret_findings = self.secrets_detector.redact(content_after)
|
|
1584
|
+
if secret_findings:
|
|
1585
|
+
summary["secrets_redacted"] = True
|
|
1586
|
+
summary["secrets_types"].extend(
|
|
1587
|
+
list({f.get("type", "unknown") for f in secret_findings})
|
|
1588
|
+
)
|
|
1589
|
+
if isinstance(secrets_redacted, str):
|
|
1590
|
+
content_after = secrets_redacted
|
|
1591
|
+
|
|
1592
|
+
pii_redacted, pii_findings = self.pii_detector.redact(content_after)
|
|
1593
|
+
if pii_findings:
|
|
1594
|
+
summary["pii_redacted"] = True
|
|
1595
|
+
summary["pii_types"].extend(
|
|
1596
|
+
list({f.get("type", "unknown") for f in pii_findings})
|
|
1597
|
+
)
|
|
1598
|
+
if isinstance(pii_redacted, str):
|
|
1599
|
+
content_after = pii_redacted
|
|
1600
|
+
|
|
1601
|
+
if content_after != content:
|
|
1602
|
+
redacted["content"] = content_after
|
|
1603
|
+
summary["applied"] = True
|
|
1604
|
+
|
|
1605
|
+
summary["pii_types"] = list(set(summary["pii_types"]))
|
|
1606
|
+
summary["secrets_types"] = list(set(summary["secrets_types"]))
|
|
1607
|
+
return redacted, summary
|
|
1608
|
+
|
|
1609
|
+
# Handle object with content attribute (e.g., OpenAI response objects)
|
|
1610
|
+
if hasattr(response, "content") and isinstance(response.content, str):
|
|
1611
|
+
# For immutable response objects, we can't modify them directly
|
|
1612
|
+
# Return the redacted text in a wrapper structure
|
|
1613
|
+
content = response.content
|
|
1614
|
+
content_after = content
|
|
1615
|
+
|
|
1616
|
+
secrets_redacted, secret_findings = self.secrets_detector.redact(content_after)
|
|
1617
|
+
if secret_findings:
|
|
1618
|
+
summary["secrets_redacted"] = True
|
|
1619
|
+
summary["secrets_types"] = list({f.get("type", "unknown") for f in secret_findings})
|
|
1620
|
+
if isinstance(secrets_redacted, str):
|
|
1621
|
+
content_after = secrets_redacted
|
|
1622
|
+
|
|
1623
|
+
pii_redacted, pii_findings = self.pii_detector.redact(content_after)
|
|
1624
|
+
if pii_findings:
|
|
1625
|
+
summary["pii_redacted"] = True
|
|
1626
|
+
summary["pii_types"] = list({f.get("type", "unknown") for f in pii_findings})
|
|
1627
|
+
if isinstance(pii_redacted, str):
|
|
1628
|
+
content_after = pii_redacted
|
|
1629
|
+
|
|
1630
|
+
if content_after != content:
|
|
1631
|
+
summary["applied"] = True
|
|
1632
|
+
# Try to modify the object if possible, otherwise return modified dict
|
|
1633
|
+
try:
|
|
1634
|
+
response.content = content_after
|
|
1635
|
+
return response, summary
|
|
1636
|
+
except (AttributeError, TypeError):
|
|
1637
|
+
# Object is immutable, return dict representation
|
|
1638
|
+
return {"content": content_after, "_original_type": type(response).__name__}, summary
|
|
1639
|
+
|
|
1640
|
+
return response, summary
|
|
1641
|
+
|
|
1642
|
+
def _extract_llm_prompt_text(self, prompt: Any) -> str | None:
|
|
1643
|
+
"""Extract a text view of the LLM prompt for scanning."""
|
|
1644
|
+
if prompt is None:
|
|
1645
|
+
return None
|
|
1646
|
+
if isinstance(prompt, str):
|
|
1647
|
+
return prompt
|
|
1648
|
+
if isinstance(prompt, dict):
|
|
1649
|
+
content = prompt.get("content")
|
|
1650
|
+
return content if isinstance(content, str) else None
|
|
1651
|
+
if isinstance(prompt, list):
|
|
1652
|
+
parts = []
|
|
1653
|
+
for item in prompt:
|
|
1654
|
+
if isinstance(item, dict) and isinstance(item.get("content"), str):
|
|
1655
|
+
parts.append(item["content"])
|
|
1656
|
+
elif hasattr(item, "content") and isinstance(item.content, str):
|
|
1657
|
+
parts.append(item.content)
|
|
1658
|
+
elif isinstance(item, str):
|
|
1659
|
+
parts.append(item)
|
|
1660
|
+
return " ".join(parts) if parts else None
|
|
1661
|
+
return None
|
|
1662
|
+
|
|
1663
|
+
def _extract_llm_response_text(self, response: Any) -> str | None:
|
|
1664
|
+
"""Extract a text view of the LLM response for scanning."""
|
|
1665
|
+
if response is None:
|
|
1666
|
+
return None
|
|
1667
|
+
if isinstance(response, str):
|
|
1668
|
+
return response
|
|
1669
|
+
if isinstance(response, dict):
|
|
1670
|
+
content = response.get("content")
|
|
1671
|
+
return content if isinstance(content, str) else None
|
|
1672
|
+
if hasattr(response, "content") and isinstance(response.content, str):
|
|
1673
|
+
return response.content
|
|
1674
|
+
if hasattr(response, "choices"):
|
|
1675
|
+
try:
|
|
1676
|
+
choice = response.choices[0]
|
|
1677
|
+
message = getattr(choice, "message", None)
|
|
1678
|
+
if message and hasattr(message, "content") and isinstance(message.content, str):
|
|
1679
|
+
return message.content
|
|
1680
|
+
except Exception:
|
|
1681
|
+
return None
|
|
1682
|
+
if isinstance(response, list):
|
|
1683
|
+
parts = []
|
|
1684
|
+
for item in response:
|
|
1685
|
+
if hasattr(item, "content") and isinstance(item.content, str):
|
|
1686
|
+
parts.append(item.content)
|
|
1687
|
+
elif isinstance(item, str):
|
|
1688
|
+
parts.append(item)
|
|
1689
|
+
return " ".join(parts) if parts else None
|
|
1690
|
+
return None
|
|
1691
|
+
|
|
1692
|
+
def record_tool_result(
|
|
1693
|
+
self,
|
|
1694
|
+
span: trace.Span,
|
|
1695
|
+
success: bool,
|
|
1696
|
+
result: Any = None,
|
|
1697
|
+
error: str | None = None,
|
|
1698
|
+
) -> None:
|
|
1699
|
+
"""Record the result of a tool call."""
|
|
1700
|
+
span.set_attribute("cortexhub.result.success", success)
|
|
1701
|
+
|
|
1702
|
+
if success:
|
|
1703
|
+
span.set_status(Status(StatusCode.OK))
|
|
1704
|
+
if not self.privacy and result is not None:
|
|
1705
|
+
span.set_attribute("cortexhub.raw.result", json.dumps(result, default=str))
|
|
1706
|
+
else:
|
|
1707
|
+
span.set_status(Status(StatusCode.ERROR, error or "Unknown error"))
|
|
1708
|
+
if error:
|
|
1709
|
+
span.set_attribute("cortexhub.error.message", error)
|
|
1710
|
+
|
|
1711
|
+
def record_llm_result(
|
|
1712
|
+
self,
|
|
1713
|
+
span: trace.Span,
|
|
1714
|
+
response: str | None = None,
|
|
1715
|
+
prompt_tokens: int | None = None,
|
|
1716
|
+
completion_tokens: int | None = None,
|
|
1717
|
+
) -> None:
|
|
1718
|
+
"""Record the result of an LLM call."""
|
|
1719
|
+
span.set_status(Status(StatusCode.OK))
|
|
1720
|
+
|
|
1721
|
+
if prompt_tokens is not None:
|
|
1722
|
+
span.set_attribute("gen_ai.usage.prompt_tokens", prompt_tokens)
|
|
1723
|
+
if completion_tokens is not None:
|
|
1724
|
+
span.set_attribute("gen_ai.usage.completion_tokens", completion_tokens)
|
|
1725
|
+
|
|
1726
|
+
# Run guardrails on response
|
|
1727
|
+
if response:
|
|
1728
|
+
self._check_guardrails(span, response, "response")
|
|
1729
|
+
|
|
1730
|
+
if not self.privacy:
|
|
1731
|
+
span.set_attribute("cortexhub.raw.response", response)
|
|
1732
|
+
|
|
1733
|
+
def _compute_context_hash(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
1734
|
+
"""Compute stable hash for idempotency."""
|
|
1735
|
+
|
|
1736
|
+
import hashlib
|
|
1737
|
+
|
|
1738
|
+
key = f"{tool_name}:{json.dumps(args, sort_keys=True, default=str)}"
|
|
1739
|
+
return f"sha256:{hashlib.sha256(key.encode()).hexdigest()}"
|
|
1740
|
+
|
|
1741
|
+
def _sanitize_policy_args(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1742
|
+
"""Coerce args into Cedar-safe JSON primitives for evaluation."""
|
|
1743
|
+
|
|
1744
|
+
def coerce_value(value: Any) -> Any:
|
|
1745
|
+
if value is None:
|
|
1746
|
+
return None
|
|
1747
|
+
if isinstance(value, bool):
|
|
1748
|
+
return value
|
|
1749
|
+
if isinstance(value, int):
|
|
1750
|
+
return value
|
|
1751
|
+
if isinstance(value, float):
|
|
1752
|
+
from decimal import Decimal
|
|
1753
|
+
|
|
1754
|
+
dec = Decimal(str(value))
|
|
1755
|
+
if dec == dec.to_integral_value():
|
|
1756
|
+
return int(dec)
|
|
1757
|
+
return str(dec)
|
|
1758
|
+
if isinstance(value, str):
|
|
1759
|
+
return value
|
|
1760
|
+
if isinstance(value, dict):
|
|
1761
|
+
cleaned: dict[str, Any] = {}
|
|
1762
|
+
for key, val in value.items():
|
|
1763
|
+
coerced = coerce_value(val)
|
|
1764
|
+
if coerced is not None:
|
|
1765
|
+
cleaned[key] = coerced
|
|
1766
|
+
return cleaned
|
|
1767
|
+
if isinstance(value, (list, tuple, set)):
|
|
1768
|
+
cleaned_list = [coerce_value(item) for item in value]
|
|
1769
|
+
return [item for item in cleaned_list if item is not None]
|
|
1770
|
+
try:
|
|
1771
|
+
return json.loads(json.dumps(value, default=str))
|
|
1772
|
+
except Exception:
|
|
1773
|
+
return str(value)
|
|
1774
|
+
|
|
1775
|
+
if not isinstance(args, dict):
|
|
1776
|
+
return {}
|
|
1777
|
+
|
|
1778
|
+
cleaned_args = coerce_value(args)
|
|
1779
|
+
if not isinstance(cleaned_args, dict):
|
|
1780
|
+
return {}
|
|
1781
|
+
|
|
1782
|
+
if "amount" in cleaned_args:
|
|
1783
|
+
from decimal import Decimal, InvalidOperation
|
|
1784
|
+
|
|
1785
|
+
amount = cleaned_args.get("amount")
|
|
1786
|
+
dec_amount: Decimal | None = None
|
|
1787
|
+
if isinstance(amount, int):
|
|
1788
|
+
dec_amount = Decimal(amount)
|
|
1789
|
+
elif isinstance(amount, str):
|
|
1790
|
+
try:
|
|
1791
|
+
dec_amount = Decimal(amount)
|
|
1792
|
+
except InvalidOperation:
|
|
1793
|
+
dec_amount = None
|
|
1794
|
+
if dec_amount is not None:
|
|
1795
|
+
quantized = dec_amount.quantize(Decimal("0.01"))
|
|
1796
|
+
if quantized == dec_amount:
|
|
1797
|
+
cleaned_args["amount_cents"] = int(
|
|
1798
|
+
(quantized * 100).to_integral_value()
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
return cleaned_args
|
|
1802
|
+
|
|
1803
|
+
def _summarize_policy_args(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1804
|
+
"""Summarize args for debug logging without leaking sensitive values."""
|
|
1805
|
+
|
|
1806
|
+
def summarize(value: Any) -> Any:
|
|
1807
|
+
if isinstance(value, dict):
|
|
1808
|
+
return {key: summarize(val) for key, val in value.items()}
|
|
1809
|
+
if isinstance(value, list):
|
|
1810
|
+
return [summarize(item) for item in value]
|
|
1811
|
+
return type(value).__name__
|
|
1812
|
+
|
|
1813
|
+
if not isinstance(args, dict):
|
|
1814
|
+
return {}
|
|
1815
|
+
if self.privacy:
|
|
1816
|
+
return summarize(args)
|
|
1817
|
+
return args
|
|
1818
|
+
|
|
1819
|
+
def _get_current_trace_id(self) -> str | None:
|
|
1820
|
+
"""Get current OpenTelemetry trace ID."""
|
|
1821
|
+
|
|
1822
|
+
span = trace.get_current_span()
|
|
1823
|
+
if span and span.get_span_context().is_valid:
|
|
1824
|
+
return format(span.get_span_context().trace_id, "032x")
|
|
1825
|
+
return None
|
|
1826
|
+
|
|
1827
|
+
def _infer_risk_category(self, decision: Decision, tool_name: str) -> str | None:
|
|
1828
|
+
"""Infer risk category from policy metadata."""
|
|
1829
|
+
|
|
1830
|
+
return None
|
|
1831
|
+
|
|
1832
|
+
def log_llm_call(
|
|
1833
|
+
self,
|
|
1834
|
+
*,
|
|
1835
|
+
trace_id: str,
|
|
1836
|
+
model: str,
|
|
1837
|
+
prompt: Any | None = None,
|
|
1838
|
+
response: Any | None = None,
|
|
1839
|
+
prompt_tokens: int | None = None,
|
|
1840
|
+
completion_tokens: int | None = None,
|
|
1841
|
+
latency_ms: float = 0.0,
|
|
1842
|
+
cost_estimate: float | None = None,
|
|
1843
|
+
guardrail_findings: Any | None = None, # Updated to Any for compatibility
|
|
1844
|
+
agent_id: str | None = None,
|
|
1845
|
+
decision: Decision | None = None,
|
|
1846
|
+
redaction_applied: bool = False,
|
|
1847
|
+
redaction_summary: dict[str, Any] | None = None,
|
|
1848
|
+
) -> None:
|
|
1849
|
+
"""Log an LLM call event with guardrail findings.
|
|
1850
|
+
|
|
1851
|
+
THIS is where guardrails matter - sensitive data should NOT go to LLMs.
|
|
1852
|
+
Called by LLM interceptors after scanning prompts.
|
|
1853
|
+
|
|
1854
|
+
NOTE: This method creates OpenTelemetry spans for auditability.
|
|
1855
|
+
"""
|
|
1856
|
+
# Create LLM span
|
|
1857
|
+
with self._tracer.start_span(
|
|
1858
|
+
name="llm.call",
|
|
1859
|
+
kind=trace.SpanKind.CLIENT,
|
|
1860
|
+
) as span:
|
|
1861
|
+
# Set standard attributes
|
|
1862
|
+
span.set_attribute("cortexhub.session.id", self.session_id)
|
|
1863
|
+
span.set_attribute("cortexhub.agent.id", agent_id or self.agent_id)
|
|
1864
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
1865
|
+
|
|
1866
|
+
raw_prompt = None
|
|
1867
|
+
raw_response = None
|
|
1868
|
+
if not self.privacy:
|
|
1869
|
+
raw_prompt = self._serialize_raw_value(prompt)
|
|
1870
|
+
raw_response = self._serialize_raw_value(response)
|
|
1871
|
+
if raw_prompt is not None:
|
|
1872
|
+
span.set_attribute("cortexhub.raw.prompt", raw_prompt)
|
|
1873
|
+
if raw_response is not None:
|
|
1874
|
+
span.set_attribute("cortexhub.raw.response", raw_response)
|
|
1875
|
+
|
|
1876
|
+
if not self.privacy:
|
|
1877
|
+
redacted_prompt = self._serialize_raw_value(
|
|
1878
|
+
self._redact_llm_prompt_preview(prompt)
|
|
1879
|
+
)
|
|
1880
|
+
redacted_response = self._serialize_raw_value(
|
|
1881
|
+
self._redact_for_display(response)
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
if redacted_prompt and redacted_prompt != raw_prompt:
|
|
1885
|
+
span.set_attribute("cortexhub.redacted.prompt", redacted_prompt)
|
|
1886
|
+
if redacted_response and redacted_response != raw_response:
|
|
1887
|
+
span.set_attribute("cortexhub.redacted.response", redacted_response)
|
|
1888
|
+
|
|
1889
|
+
if prompt_tokens is not None:
|
|
1890
|
+
span.set_attribute("gen_ai.usage.prompt_tokens", prompt_tokens)
|
|
1891
|
+
if completion_tokens is not None:
|
|
1892
|
+
span.set_attribute("gen_ai.usage.completion_tokens", completion_tokens)
|
|
1893
|
+
|
|
1894
|
+
if decision is not None:
|
|
1895
|
+
span.add_event(
|
|
1896
|
+
"policy.decision",
|
|
1897
|
+
attributes={
|
|
1898
|
+
"decision.effect": decision.effect.value,
|
|
1899
|
+
"decision.policy_id": decision.policy_id or "",
|
|
1900
|
+
"decision.reasoning": decision.reasoning,
|
|
1901
|
+
"decision.policy_name": decision.policy_name or "",
|
|
1902
|
+
},
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
if redaction_applied:
|
|
1906
|
+
span.set_attribute("cortexhub.redaction.applied", True)
|
|
1907
|
+
if redaction_summary:
|
|
1908
|
+
span.set_attribute(
|
|
1909
|
+
"cortexhub.redaction.pii_applied", bool(redaction_summary.get("pii_redacted"))
|
|
1910
|
+
)
|
|
1911
|
+
span.set_attribute(
|
|
1912
|
+
"cortexhub.redaction.secrets_applied",
|
|
1913
|
+
bool(redaction_summary.get("secrets_redacted")),
|
|
1914
|
+
)
|
|
1915
|
+
pii_types = redaction_summary.get("pii_types", [])
|
|
1916
|
+
secrets_types = redaction_summary.get("secrets_types", [])
|
|
1917
|
+
if pii_types:
|
|
1918
|
+
span.set_attribute(
|
|
1919
|
+
"cortexhub.redaction.pii_types",
|
|
1920
|
+
json.dumps(pii_types),
|
|
1921
|
+
)
|
|
1922
|
+
if secrets_types:
|
|
1923
|
+
span.set_attribute(
|
|
1924
|
+
"cortexhub.redaction.secrets_types",
|
|
1925
|
+
json.dumps(secrets_types),
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
# Add guardrail findings as span events
|
|
1929
|
+
if guardrail_findings:
|
|
1930
|
+
# Handle guardrail findings format
|
|
1931
|
+
if hasattr(guardrail_findings, 'pii_in_prompt') and guardrail_findings.pii_in_prompt.get("detected"):
|
|
1932
|
+
pii_data = guardrail_findings.pii_in_prompt
|
|
1933
|
+
unique_count = pii_data.get("unique_count", pii_data.get("count", 0))
|
|
1934
|
+
total_count = pii_data.get("count", 0)
|
|
1935
|
+
summary = pii_data.get("summary", f"{unique_count} PII items")
|
|
1936
|
+
|
|
1937
|
+
pii_attrs = {
|
|
1938
|
+
"pii.detected": True,
|
|
1939
|
+
"pii.types": pii_data.get("types", []),
|
|
1940
|
+
"pii.unique_count": unique_count,
|
|
1941
|
+
"pii.total_occurrences": total_count,
|
|
1942
|
+
"pii.summary": summary,
|
|
1943
|
+
}
|
|
1944
|
+
# Include counts_per_type if available
|
|
1945
|
+
if pii_data.get("counts_per_type"):
|
|
1946
|
+
pii_attrs["pii.counts_per_type"] = json.dumps(pii_data["counts_per_type"])
|
|
1947
|
+
if pii_data.get("unique_per_type"):
|
|
1948
|
+
pii_attrs["pii.unique_per_type"] = json.dumps(pii_data["unique_per_type"])
|
|
1949
|
+
span.add_event("guardrail.pii_in_prompt", attributes=pii_attrs)
|
|
1950
|
+
logger.warning(
|
|
1951
|
+
"⚠️ PII detected in LLM prompt",
|
|
1952
|
+
model=model,
|
|
1953
|
+
summary=summary,
|
|
1954
|
+
types=pii_data.get("types", []),
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
if hasattr(guardrail_findings, 'secrets_in_prompt') and guardrail_findings.secrets_in_prompt.get("detected"):
|
|
1958
|
+
secrets_attrs = {
|
|
1959
|
+
"secrets.detected": True,
|
|
1960
|
+
"secrets.types": guardrail_findings.secrets_in_prompt.get("types", []),
|
|
1961
|
+
"secrets.count": guardrail_findings.secrets_in_prompt.get("count", 0),
|
|
1962
|
+
}
|
|
1963
|
+
# Include counts_per_type if available
|
|
1964
|
+
if guardrail_findings.secrets_in_prompt.get("counts_per_type"):
|
|
1965
|
+
secrets_attrs["secrets.counts_per_type"] = json.dumps(guardrail_findings.secrets_in_prompt["counts_per_type"])
|
|
1966
|
+
span.add_event("guardrail.secrets_in_prompt", attributes=secrets_attrs)
|
|
1967
|
+
logger.error(
|
|
1968
|
+
"🚨 Secrets detected in LLM prompt",
|
|
1969
|
+
model=model,
|
|
1970
|
+
count=guardrail_findings.secrets_in_prompt.get("count", 0),
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
if hasattr(guardrail_findings, 'prompt_manipulation') and guardrail_findings.prompt_manipulation.get("detected"):
|
|
1974
|
+
span.add_event(
|
|
1975
|
+
"guardrail.prompt_manipulation",
|
|
1976
|
+
attributes={
|
|
1977
|
+
"manipulation.detected": True,
|
|
1978
|
+
"manipulation.patterns": guardrail_findings.prompt_manipulation.get("patterns", []),
|
|
1979
|
+
}
|
|
1980
|
+
)
|
|
1981
|
+
logger.warning(
|
|
1982
|
+
"⚠️ Prompt manipulation detected",
|
|
1983
|
+
model=model,
|
|
1984
|
+
patterns=guardrail_findings.prompt_manipulation.get("patterns", []),
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
# =========================================================================
|
|
1988
|
+
# PUBLIC UTILITY METHODS
|
|
1989
|
+
# =========================================================================
|
|
1990
|
+
|
|
1991
|
+
def export_telemetry(self) -> bool:
|
|
1992
|
+
"""Flush any pending OpenTelemetry spans (non-blocking)."""
|
|
1993
|
+
if self._tracer_provider:
|
|
1994
|
+
# Force flush any pending spans
|
|
1995
|
+
try:
|
|
1996
|
+
self._tracer_provider.force_flush(timeout_millis=5000)
|
|
1997
|
+
except TypeError:
|
|
1998
|
+
self._tracer_provider.force_flush()
|
|
1999
|
+
return True
|
|
2000
|
+
|
|
2001
|
+
def has_policies(self) -> bool:
|
|
2002
|
+
"""Check if enforcement mode is active (policies loaded from CortexHub).
|
|
2003
|
+
|
|
2004
|
+
Returns:
|
|
2005
|
+
True if policies are loaded and enforcement is active,
|
|
2006
|
+
False if in observation mode (no policies).
|
|
2007
|
+
"""
|
|
2008
|
+
return self.enforce
|
|
2009
|
+
|
|
2010
|
+
def sync_policies(self) -> bool:
|
|
2011
|
+
"""Manually sync policies from cloud.
|
|
2012
|
+
|
|
2013
|
+
Useful after enabling policies in the CortexHub dashboard
|
|
2014
|
+
to reload them without restarting your application.
|
|
2015
|
+
|
|
2016
|
+
Returns:
|
|
2017
|
+
True if policies were loaded/refreshed,
|
|
2018
|
+
False if no policies available or sync failed.
|
|
2019
|
+
"""
|
|
2020
|
+
if not self.backend:
|
|
2021
|
+
return False
|
|
2022
|
+
|
|
2023
|
+
# Re-validate API key to get latest policies
|
|
2024
|
+
is_valid, sdk_config = self.backend.validate_api_key()
|
|
2025
|
+
if is_valid and sdk_config and sdk_config.has_policies:
|
|
2026
|
+
self._init_enforcement_mode(sdk_config)
|
|
2027
|
+
logger.info("Policies reloaded after sync")
|
|
2028
|
+
return True
|
|
2029
|
+
|
|
2030
|
+
return False
|
|
2031
|
+
|
|
2032
|
+
def protect(self, framework: "Framework") -> None:
|
|
2033
|
+
"""Apply governance adapter for the specified framework.
|
|
2034
|
+
|
|
2035
|
+
This is called automatically by cortexhub.init(). You typically
|
|
2036
|
+
don't need to call this directly.
|
|
2037
|
+
|
|
2038
|
+
Args:
|
|
2039
|
+
framework: The Framework enum value to protect
|
|
2040
|
+
|
|
2041
|
+
Raises:
|
|
2042
|
+
ImportError: If framework dependencies are not installed
|
|
2043
|
+
ValueError: If framework is not supported
|
|
2044
|
+
"""
|
|
2045
|
+
from cortexhub.frameworks import Framework
|
|
2046
|
+
|
|
2047
|
+
# Apply MCP interceptor (works with all frameworks)
|
|
2048
|
+
self.mcp_interceptor.apply_all()
|
|
2049
|
+
|
|
2050
|
+
# Apply framework-specific adapter
|
|
2051
|
+
if framework == Framework.LANGGRAPH:
|
|
2052
|
+
try:
|
|
2053
|
+
from cortexhub.adapters.langgraph import LangGraphAdapter
|
|
2054
|
+
adapter = LangGraphAdapter(self)
|
|
2055
|
+
adapter.patch()
|
|
2056
|
+
logger.info("LangGraph adapter applied", framework="langgraph")
|
|
2057
|
+
except ImportError as e:
|
|
2058
|
+
raise ImportError(
|
|
2059
|
+
"LangGraph dependencies not installed. "
|
|
2060
|
+
"Install with: pip install cortexhub[langgraph]"
|
|
2061
|
+
) from e
|
|
2062
|
+
|
|
2063
|
+
elif framework == Framework.CREWAI:
|
|
2064
|
+
try:
|
|
2065
|
+
from cortexhub.adapters.crewai import CrewAIAdapter
|
|
2066
|
+
adapter = CrewAIAdapter(self)
|
|
2067
|
+
adapter.patch()
|
|
2068
|
+
logger.info("CrewAI adapter applied", framework="crewai")
|
|
2069
|
+
except ImportError as e:
|
|
2070
|
+
raise ImportError(
|
|
2071
|
+
"CrewAI dependencies not installed. "
|
|
2072
|
+
"Install with: pip install cortexhub[crewai]"
|
|
2073
|
+
) from e
|
|
2074
|
+
|
|
2075
|
+
elif framework == Framework.OPENAI_AGENTS:
|
|
2076
|
+
try:
|
|
2077
|
+
from cortexhub.adapters.openai_agents import OpenAIAgentsAdapter
|
|
2078
|
+
adapter = OpenAIAgentsAdapter(self)
|
|
2079
|
+
adapter.patch()
|
|
2080
|
+
logger.info("OpenAI Agents adapter applied", framework="openai_agents")
|
|
2081
|
+
except ImportError as e:
|
|
2082
|
+
raise ImportError(
|
|
2083
|
+
"OpenAI Agents dependencies not installed. "
|
|
2084
|
+
"Install with: pip install cortexhub[openai-agents]"
|
|
2085
|
+
) from e
|
|
2086
|
+
|
|
2087
|
+
elif framework == Framework.CLAUDE_AGENTS:
|
|
2088
|
+
try:
|
|
2089
|
+
from cortexhub.adapters.claude_agents import ClaudeAgentsAdapter
|
|
2090
|
+
adapter = ClaudeAgentsAdapter(self)
|
|
2091
|
+
adapter.patch()
|
|
2092
|
+
logger.info("Claude Agents adapter applied", framework="claude_agents")
|
|
2093
|
+
except ImportError as e:
|
|
2094
|
+
raise ImportError(
|
|
2095
|
+
"Claude Agent SDK dependencies not installed. "
|
|
2096
|
+
"Install with: pip install cortexhub[claude-agents]"
|
|
2097
|
+
) from e
|
|
2098
|
+
else:
|
|
2099
|
+
raise ValueError(
|
|
2100
|
+
f"Unknown framework: {framework}. "
|
|
2101
|
+
f"Supported: LANGGRAPH, CREWAI, OPENAI_AGENTS, CLAUDE_AGENTS"
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
def auto_protect(
|
|
2105
|
+
self, enable_llm: bool = True, enable_mcp: bool = True, enable_tools: bool = True
|
|
2106
|
+
) -> None:
|
|
2107
|
+
"""[DEPRECATED] Auto-detect and patch supported frameworks.
|
|
2108
|
+
|
|
2109
|
+
Use cortexhub.init(framework=Framework.XXX) instead.
|
|
2110
|
+
"""
|
|
2111
|
+
import warnings
|
|
2112
|
+
warnings.warn(
|
|
2113
|
+
"auto_protect() is deprecated. Use cortexhub.init(framework=Framework.XXX) instead.",
|
|
2114
|
+
DeprecationWarning,
|
|
2115
|
+
stacklevel=2,
|
|
2116
|
+
)
|
|
2117
|
+
|
|
2118
|
+
if enable_mcp:
|
|
2119
|
+
self.mcp_interceptor.apply_all()
|
|
2120
|
+
|
|
2121
|
+
from cortexhub.auto_protect import auto_protect_frameworks
|
|
2122
|
+
auto_protect_frameworks(self, enable_llm=enable_llm, enable_tools=enable_tools)
|
|
2123
|
+
|
|
2124
|
+
logger.info(
|
|
2125
|
+
"Auto-protection enabled",
|
|
2126
|
+
llm=enable_llm,
|
|
2127
|
+
mcp=enable_mcp,
|
|
2128
|
+
tools=enable_tools,
|
|
2129
|
+
)
|
|
2130
|
+
|
|
2131
|
+
def shutdown(self) -> None:
|
|
2132
|
+
"""Shutdown OpenTelemetry and flush spans."""
|
|
2133
|
+
try:
|
|
2134
|
+
if hasattr(self, '_tracer_provider') and self._tracer_provider:
|
|
2135
|
+
try:
|
|
2136
|
+
self._tracer_provider.shutdown(timeout_millis=5000)
|
|
2137
|
+
except TypeError:
|
|
2138
|
+
self._tracer_provider.shutdown()
|
|
2139
|
+
if hasattr(self, 'backend') and self.backend:
|
|
2140
|
+
self.backend.close()
|
|
2141
|
+
except Exception as e:
|
|
2142
|
+
logger.debug("Error during shutdown", error=str(e))
|
|
2143
|
+
|
|
2144
|
+
def __del__(self):
|
|
2145
|
+
"""Cleanup - flush telemetry on shutdown."""
|
|
2146
|
+
try:
|
|
2147
|
+
self.shutdown()
|
|
2148
|
+
except Exception:
|
|
2149
|
+
pass
|