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/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