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.
@@ -0,0 +1,348 @@
1
+ """Backend API client for SDK."""
2
+
3
+ import os
4
+ import re
5
+ import structlog
6
+ import httpx
7
+ from typing import Any
8
+ from dataclasses import dataclass
9
+
10
+ from cortexhub.version import __version__
11
+
12
+ logger = structlog.get_logger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class CustomPattern:
17
+ """Custom regex pattern for detection."""
18
+ name: str
19
+ pattern: str
20
+ description: str | None = None
21
+ enabled: bool = True
22
+
23
+
24
+ @dataclass
25
+ class GuardrailConfig:
26
+ """Configuration for guardrail policies (PII/Secrets).
27
+
28
+ Controls which types are detected and redacted.
29
+ """
30
+ action: str = "redact" # redact, block, allow
31
+ pii_types: list[str] | None = None # None = all types
32
+ secret_types: list[str] | None = None
33
+ custom_patterns: list[CustomPattern] | None = None
34
+ # Where to apply redaction: "input_only", "output_only", or "both"
35
+ redaction_scope: str = "both"
36
+
37
+
38
+ @dataclass
39
+ class PolicyBundle:
40
+ """Policy bundle from backend.
41
+
42
+ When policies is empty, SDK runs in observation mode.
43
+ When policies has content, SDK runs in enforcement mode.
44
+ """
45
+ version: str
46
+ policies: str # Cedar policies (empty = observation mode)
47
+ schema: dict[str, Any] | None = None
48
+ policy_metadata: dict[str, dict[str, Any]] | None = None
49
+ # Guardrail configurations (keyed by policy type: "pii", "secrets")
50
+ guardrail_configs: dict[str, GuardrailConfig] | None = None
51
+
52
+
53
+ @dataclass
54
+ class SDKConfig:
55
+ """SDK configuration from backend.
56
+
57
+ Returned during API key validation.
58
+ """
59
+ project_id: str
60
+ organization_id: str
61
+ plan: str
62
+ policies: PolicyBundle
63
+
64
+ @property
65
+ def has_policies(self) -> bool:
66
+ """Check if policies are present (enforcement mode)."""
67
+ return bool(self.policies.policies.strip())
68
+
69
+
70
+ class BackendClient:
71
+ """Client for communicating with CortexHub backend.
72
+
73
+ Handles:
74
+ - API key validation + policy retrieval
75
+ """
76
+
77
+ def __init__(self, api_key: str | None, backend_url: str):
78
+ """Initialize backend client.
79
+
80
+ Args:
81
+ api_key: API key for authentication
82
+ backend_url: Backend API URL
83
+ """
84
+ self.api_key = api_key
85
+ self.backend_url = backend_url.rstrip("/")
86
+ self._client: httpx.Client | None = None
87
+
88
+ if api_key:
89
+ self._client = httpx.Client(
90
+ base_url=self.backend_url,
91
+ headers={"X-API-Key": api_key},
92
+ timeout=10.0,
93
+ )
94
+
95
+ def validate_api_key(self) -> tuple[bool, SDKConfig | None]:
96
+ """Validate API key with backend and get SDK configuration.
97
+
98
+ Returns:
99
+ (is_valid, SDKConfig or None)
100
+
101
+ SDKConfig includes:
102
+ - project_id, organization_id, environment
103
+ - policies (empty = observation mode, non-empty = enforcement mode)
104
+ """
105
+ if not self.api_key or not self._client:
106
+ return False, None
107
+
108
+ try:
109
+ # Note: base_url already includes /v1, so just /api-keys/validate
110
+ response = self._client.post(
111
+ "/api-keys/validate",
112
+ headers={"X-API-Key": self.api_key},
113
+ timeout=5.0,
114
+ )
115
+
116
+ if response.status_code == 200:
117
+ data = response.json()
118
+
119
+ # Check if valid
120
+ if not data.get("valid", False):
121
+ logger.warning("API key validation failed", message=data.get("message"))
122
+ return False, None
123
+
124
+ # Parse policy bundle from backend
125
+ # Backend returns: { policies: { version, policies: [...policy objects...] } }
126
+ policies_data = data.get("policies", {})
127
+
128
+ # Convert policy objects to Cedar string if needed
129
+ raw_policies = policies_data.get("policies", [])
130
+ guardrail_configs: dict[str, GuardrailConfig] = {}
131
+
132
+ if isinstance(raw_policies, list):
133
+ # Backend returns list of policy objects - extract Cedar code
134
+ cedar_parts = []
135
+ policy_map = {}
136
+ cedar_index = 0
137
+ for p in raw_policies:
138
+ if p.get("cedar_policy"):
139
+ cedar_parts.append(f"// Policy: {p.get('name')} (tool: {p.get('tool_name')})")
140
+ cedar_parts.append(p.get("cedar_policy"))
141
+ cedar_parts.append("")
142
+ policy_text = p.get("cedar_policy") or ""
143
+ statement_count = len(
144
+ re.findall(r"\b(permit|forbid)\s*\(", policy_text)
145
+ )
146
+ if statement_count == 0:
147
+ continue
148
+ for _ in range(statement_count):
149
+ policy_map[f"policy{cedar_index}"] = {
150
+ "name": p.get("name"),
151
+ "effect": p.get("effect"),
152
+ "tool_name": p.get("tool_name"),
153
+ "policy_document_id": p.get("id"),
154
+ "approval_destination": p.get("approval_destination"),
155
+ "approval_webhook_id": p.get("approval_webhook_id"),
156
+ }
157
+ cedar_index += 1
158
+
159
+ # Extract guardrail config if present
160
+ gc = p.get("guardrail_config")
161
+ if gc:
162
+ policy_key = p.get("policy_key", "")
163
+ if "pii" in policy_key:
164
+ custom_patterns = []
165
+ # Handle None explicitly (custom_patterns can be null in JSON)
166
+ raw_patterns = gc.get("custom_patterns") or []
167
+ for cp in raw_patterns:
168
+ custom_patterns.append(CustomPattern(
169
+ name=cp.get("name", ""),
170
+ pattern=cp.get("pattern", ""),
171
+ description=cp.get("description"),
172
+ enabled=cp.get("enabled", True),
173
+ ))
174
+ guardrail_configs["pii"] = GuardrailConfig(
175
+ action=gc.get("action", "redact"),
176
+ pii_types=gc.get("pii_types"),
177
+ custom_patterns=custom_patterns if custom_patterns else None,
178
+ redaction_scope=gc.get("redaction_scope", "both"),
179
+ )
180
+ elif "secrets" in policy_key:
181
+ custom_patterns = []
182
+ raw_patterns = gc.get("custom_patterns") or []
183
+ for cp in raw_patterns:
184
+ custom_patterns.append(CustomPattern(
185
+ name=cp.get("name", ""),
186
+ pattern=cp.get("pattern", ""),
187
+ description=cp.get("description"),
188
+ enabled=cp.get("enabled", True),
189
+ ))
190
+ guardrail_configs["secrets"] = GuardrailConfig(
191
+ action=gc.get("action", "redact"),
192
+ secret_types=gc.get("secret_types"),
193
+ custom_patterns=custom_patterns if custom_patterns else None,
194
+ redaction_scope=gc.get("redaction_scope", "both"),
195
+ )
196
+ policies_str = "\n".join(cedar_parts)
197
+ else:
198
+ policies_str = raw_policies or ""
199
+ policy_map = {}
200
+
201
+ policies = PolicyBundle(
202
+ version=policies_data.get("version", "0"),
203
+ policies=policies_str,
204
+ schema=policies_data.get("schema"),
205
+ policy_metadata=policy_map,
206
+ guardrail_configs=guardrail_configs if guardrail_configs else None,
207
+ )
208
+
209
+ config = SDKConfig(
210
+ project_id=data.get("project_id", ""),
211
+ organization_id=data.get("org_id", ""),
212
+ plan=data.get("plan", "free"),
213
+ policies=policies,
214
+ )
215
+
216
+ mode = "enforcement" if config.has_policies else "observation"
217
+ logger.info(
218
+ "API key validated",
219
+ project_id=config.project_id,
220
+ mode=mode,
221
+ policy_version=policies.version,
222
+ )
223
+ if os.getenv("CORTEXHUB_DEBUG_POLICY_BUNDLE", "").lower() in ("1", "true", "yes"):
224
+ logger.info(
225
+ "Policy bundle received",
226
+ policy_count=len(policy_map),
227
+ policy_ids=list(policy_map.keys()),
228
+ policy_effects={k: v.get("effect") for k, v in policy_map.items()},
229
+ )
230
+ if os.getenv("CORTEXHUB_DEBUG_POLICY_TEXT", "").lower() in (
231
+ "1",
232
+ "true",
233
+ "yes",
234
+ ):
235
+ logger.info("Policy bundle cedar", policies=policies_str)
236
+ return True, config
237
+ else:
238
+ error_detail = response.json().get("detail", "Invalid API key")
239
+ logger.warning("API key validation failed", error=error_detail)
240
+ return False, None
241
+
242
+ except httpx.ConnectError:
243
+ logger.warning(
244
+ "Backend unreachable - running in offline observation mode",
245
+ backend_url=self.backend_url,
246
+ )
247
+ return False, None
248
+ except Exception as e:
249
+ logger.error("API key validation error", error=str(e))
250
+ return False, None
251
+
252
+ def close(self):
253
+ """Close the HTTP client."""
254
+ if self._client:
255
+ self._client.close()
256
+
257
+ def register_tool_inventory(
258
+ self,
259
+ *,
260
+ agent_id: str,
261
+ framework: str,
262
+ tools: list[dict],
263
+ ) -> dict[str, Any]:
264
+ """Register agent tool inventory on init. Overwrites previous."""
265
+
266
+ if not self._client:
267
+ return {"agent_id": agent_id, "tools_count": 0}
268
+
269
+ try:
270
+ response = self._client.post(
271
+ "/tool-inventory",
272
+ json={
273
+ "agent_id": agent_id,
274
+ "framework": framework,
275
+ "sdk_version": __version__,
276
+ "tools": tools,
277
+ },
278
+ )
279
+ return response.json()
280
+ except Exception as e:
281
+ logger.warning("Failed to register tool inventory", error=str(e))
282
+ return {"agent_id": agent_id, "tools_count": len(tools)}
283
+
284
+ def create_approval(
285
+ self,
286
+ *,
287
+ run_id: str,
288
+ trace_id: str | None,
289
+ tool_name: str,
290
+ tool_args_values: dict[str, Any] | None,
291
+ context_hash: str,
292
+ policy_id: str,
293
+ policy_name: str,
294
+ policy_explanation: str,
295
+ risk_category: str | None,
296
+ agent_id: str,
297
+ framework: str,
298
+ environment: str | None = None,
299
+ expires_in_seconds: int = 3600,
300
+ ) -> dict[str, Any]:
301
+ """Create approval request. Idempotent on natural key."""
302
+
303
+ if not self._client:
304
+ raise RuntimeError("Backend client not initialized")
305
+
306
+ response = self._client.post(
307
+ "/approvals",
308
+ json={
309
+ "run_id": run_id,
310
+ "trace_id": trace_id,
311
+ "tool_name": tool_name,
312
+ "tool_args_values": tool_args_values,
313
+ "context_hash": context_hash,
314
+ "policy_id": policy_id,
315
+ "policy_name": policy_name,
316
+ "policy_explanation": policy_explanation,
317
+ "risk_category": risk_category,
318
+ "agent_id": agent_id,
319
+ "framework": framework,
320
+ "environment": environment,
321
+ "expires_in_seconds": expires_in_seconds,
322
+ },
323
+ )
324
+ try:
325
+ response.raise_for_status()
326
+ except httpx.HTTPStatusError:
327
+ logger.error(
328
+ "Approval creation failed",
329
+ status_code=response.status_code,
330
+ response_text=response.text,
331
+ )
332
+ raise
333
+
334
+ try:
335
+ payload = response.json()
336
+ except ValueError as exc:
337
+ logger.error(
338
+ "Approval creation returned invalid JSON",
339
+ status_code=response.status_code,
340
+ response_text=response.text,
341
+ )
342
+ raise RuntimeError("Approval creation returned invalid JSON") from exc
343
+
344
+ if not payload.get("approval_id"):
345
+ logger.error("Approval creation missing approval_id", payload=payload)
346
+ raise RuntimeError("Approval creation response missing approval_id")
347
+
348
+ return payload