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,96 @@
1
+ """MCP (Model Context Protocol) server interceptor.
2
+
3
+ Intercepts calls to MCP servers and enforces policies before execution.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ import structlog
9
+
10
+ logger = structlog.get_logger(__name__)
11
+
12
+
13
+ class MCPInterceptor:
14
+ """Intercepts and governs MCP server calls.
15
+
16
+ MCP provides:
17
+ - Tool/resource discovery
18
+ - Prompts and sampling
19
+ - Bidirectional communication with context providers
20
+ """
21
+
22
+ def __init__(self, cortex_hub: Any): # Type: CortexHub
23
+ """Initialize MCP interceptor.
24
+
25
+ Args:
26
+ cortex_hub: CortexHub instance for policy enforcement
27
+ """
28
+ self.cortex_hub = cortex_hub
29
+ logger.info("MCP interceptor initialized")
30
+
31
+ def intercept_mcp_client(self) -> None:
32
+ """Intercept MCP client calls."""
33
+ try:
34
+ # MCP client library (if available)
35
+ from mcp import Client
36
+
37
+ if hasattr(Client, "_original_call_tool"):
38
+ logger.info("MCP client already intercepted")
39
+ return
40
+
41
+ original_call_tool = Client.call_tool
42
+
43
+ async def governed_call_tool(self, server_name: str, tool_name: str, arguments: dict):
44
+ from cortexhub.policy.models import AuthorizationRequest
45
+
46
+ request = AuthorizationRequest.create(
47
+ principal_id=self.cortex_hub.session_id,
48
+ action_name=f"mcp.{tool_name}",
49
+ resource_id=f"{server_name}.{tool_name}",
50
+ args=arguments,
51
+ framework="mcp",
52
+ )
53
+
54
+ self.cortex_hub.enforce(request)
55
+ return await original_call_tool(self, server_name, tool_name, arguments)
56
+
57
+ Client.call_tool = governed_call_tool
58
+ Client._original_call_tool = original_call_tool
59
+
60
+ logger.info("MCP client interceptor applied")
61
+
62
+ except ImportError:
63
+ logger.debug("MCP client not available, skipping interception")
64
+ except Exception as e:
65
+ logger.error("Failed to intercept MCP client", error=str(e))
66
+
67
+ def apply_all(self) -> None:
68
+ """Apply all MCP interceptors."""
69
+ self.intercept_mcp_client()
70
+ logger.info("All MCP interceptors applied")
71
+
72
+ async def discover_mcp_tools(self, mcp_client) -> list[dict]:
73
+ """Discover tools from MCP server via list_tools()."""
74
+
75
+ tools: list[dict] = []
76
+ try:
77
+ result = await mcp_client.list_tools()
78
+ for tool in result.tools:
79
+ tools.append(
80
+ {
81
+ "name": tool.name,
82
+ "description": tool.description,
83
+ "parameters_schema": {
84
+ "type": "object",
85
+ "properties": tool.inputSchema.get("properties", {}),
86
+ "required": tool.inputSchema.get("required", []),
87
+ }
88
+ if tool.inputSchema
89
+ else None,
90
+ "source": "mcp",
91
+ "mcp_server_name": getattr(mcp_client, "server_name", None),
92
+ }
93
+ )
94
+ except Exception as e:
95
+ logger.warning("Failed to discover MCP tools", error=str(e))
96
+ return tools
cortexhub/pipeline.py ADDED
@@ -0,0 +1,92 @@
1
+ """Unified governance pipeline for adapters that can't use SDK entrypoint directly.
2
+
3
+ This is a thin wrapper around cortex_hub.execute_governed_tool() for adapters
4
+ that need to create governed wrappers (e.g., CrewAI, LlamaIndex).
5
+
6
+ Architectural rules:
7
+ - This is a convenience wrapper, not a separate pipeline
8
+ - All governance logic lives in CortexHub client
9
+ - Adapters should prefer calling SDK entrypoint directly when possible
10
+ """
11
+
12
+ import asyncio
13
+ from typing import Any, Callable
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+
20
+ def is_async_callable(fn: Callable) -> bool:
21
+ """Check if a callable is async."""
22
+ return asyncio.iscoroutinefunction(fn)
23
+
24
+
25
+ def govern_execution(
26
+ tool_fn: Callable,
27
+ tool_metadata: dict[str, Any],
28
+ cortex_hub: Any, # Type: CortexHub
29
+ ) -> Callable:
30
+ """Create governed wrapper for sync or async execution.
31
+
32
+ This wraps the SDK's execute_governed_tool() for adapters that need
33
+ to create function wrappers.
34
+
35
+ Args:
36
+ tool_fn: The actual function to execute
37
+ tool_metadata: Tool information (name, framework, description)
38
+ cortex_hub: CortexHub instance
39
+
40
+ Returns:
41
+ Wrapped function (sync or async based on tool_fn)
42
+ """
43
+ execution_kind = tool_metadata.get("kind", "tool")
44
+ tool_name = tool_metadata.get("name", "unknown")
45
+ tool_description = tool_metadata.get("description")
46
+ framework = tool_metadata.get("framework", "unknown")
47
+ model = tool_metadata.get("model", "unknown")
48
+ prompt = tool_metadata.get("prompt")
49
+ call_original = tool_metadata.get("call_original")
50
+ if call_original is None:
51
+ call_original = tool_fn
52
+
53
+ if execution_kind == "llm":
54
+ if is_async_callable(call_original):
55
+ async def async_wrapper(*args, **kwargs):
56
+ return await cortex_hub.execute_governed_llm_call_async(
57
+ model=model,
58
+ prompt=prompt,
59
+ framework=framework,
60
+ call_original=call_original,
61
+ )
62
+ return async_wrapper
63
+ else:
64
+ def sync_wrapper(*args, **kwargs):
65
+ return cortex_hub.execute_governed_llm_call(
66
+ model=model,
67
+ prompt=prompt,
68
+ framework=framework,
69
+ call_original=call_original,
70
+ )
71
+ return sync_wrapper
72
+
73
+ if is_async_callable(tool_fn):
74
+ async def async_wrapper(*args, **kwargs):
75
+ return await cortex_hub.execute_governed_tool_async(
76
+ tool_name=tool_name,
77
+ tool_description=tool_description,
78
+ args=kwargs, # Use kwargs for structured arguments
79
+ framework=framework,
80
+ call_original=lambda: tool_fn(*args, **kwargs),
81
+ )
82
+ return async_wrapper
83
+ else:
84
+ def sync_wrapper(*args, **kwargs):
85
+ return cortex_hub.execute_governed_tool(
86
+ tool_name=tool_name,
87
+ tool_description=tool_description,
88
+ args=kwargs, # Use kwargs for structured arguments
89
+ framework=framework,
90
+ call_original=lambda: tool_fn(*args, **kwargs),
91
+ )
92
+ return sync_wrapper
@@ -0,0 +1,6 @@
1
+ """Policy engine for Cedar evaluation."""
2
+
3
+ from cortexhub.policy.effects import Decision, Effect
4
+ from cortexhub.policy.models import AuthorizationRequest
5
+
6
+ __all__ = ["AuthorizationRequest", "Decision", "Effect"]
@@ -0,0 +1,87 @@
1
+ """Decision effects from policy evaluation.
2
+
3
+ Critical: Determinism guarantee - Same AuthorizationRequest MUST always produce
4
+ same Decision. This is essential for testing and auditability.
5
+ """
6
+
7
+ from enum import Enum
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class Effect(str, Enum):
13
+ """Policy decision effect."""
14
+
15
+ ALLOW = "allow"
16
+ DENY = "deny"
17
+ ESCALATE = "escalate"
18
+ REDACT = "redact" # PII/secrets were redacted before LLM call
19
+
20
+
21
+ class Decision(BaseModel):
22
+ """Result of policy evaluation.
23
+
24
+ Attributes:
25
+ effect: The decision (ALLOW/DENY/ESCALATE)
26
+ reasoning: Human-readable explanation
27
+ policy_id: Which policy triggered this decision (if any)
28
+ policy_name: Friendly policy name (if available)
29
+ """
30
+
31
+ effect: Effect
32
+ reasoning: str
33
+ policy_id: str | None = None
34
+ policy_name: str | None = None
35
+
36
+ def is_allowed(self) -> bool:
37
+ """Check if the decision allows execution."""
38
+ return self.effect == Effect.ALLOW
39
+
40
+ def is_denied(self) -> bool:
41
+ """Check if the decision denies execution."""
42
+ return self.effect == Effect.DENY
43
+
44
+ def requires_approval(self) -> bool:
45
+ """Check if the decision requires approval (escalation)."""
46
+ return self.effect == Effect.ESCALATE
47
+
48
+ @classmethod
49
+ def allow(
50
+ cls,
51
+ reasoning: str = "Allowed by policy",
52
+ policy_id: str | None = None,
53
+ policy_name: str | None = None,
54
+ ):
55
+ """Create an ALLOW decision."""
56
+ return cls(
57
+ effect=Effect.ALLOW, reasoning=reasoning, policy_id=policy_id, policy_name=policy_name
58
+ )
59
+
60
+ @classmethod
61
+ def deny(cls, reasoning: str, policy_id: str | None = None, policy_name: str | None = None):
62
+ """Create a DENY decision."""
63
+ return cls(
64
+ effect=Effect.DENY, reasoning=reasoning, policy_id=policy_id, policy_name=policy_name
65
+ )
66
+
67
+ @classmethod
68
+ def escalate(
69
+ cls, reasoning: str, policy_id: str | None = None, policy_name: str | None = None
70
+ ):
71
+ """Create an ESCALATE decision."""
72
+ return cls(
73
+ effect=Effect.ESCALATE, reasoning=reasoning, policy_id=policy_id, policy_name=policy_name
74
+ )
75
+
76
+ @classmethod
77
+ def redact(
78
+ cls, reasoning: str, policy_id: str | None = None, policy_name: str | None = None
79
+ ):
80
+ """Create a REDACT decision (PII/secrets were redacted before execution)."""
81
+ return cls(
82
+ effect=Effect.REDACT, reasoning=reasoning, policy_id=policy_id, policy_name=policy_name
83
+ )
84
+
85
+ def is_redacted(self) -> bool:
86
+ """Check if the decision resulted in redaction."""
87
+ return self.effect == Effect.REDACT
@@ -0,0 +1,267 @@
1
+ """Policy evaluator using Cedar.
2
+
3
+ Architectural invariants (from AGENTS.md):
4
+ - MUST NOT read files directly (use loader.py)
5
+ - MUST NOT make decisions (only evaluate)
6
+
7
+ Uses cedarpy for production-grade policy evaluation.
8
+ """
9
+
10
+ import os
11
+ import time
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ import structlog
16
+
17
+ from cortexhub.errors import PolicyLoadError
18
+
19
+ try:
20
+ import cedarpy as cedar_module
21
+ except Exception as exc:
22
+ raise PolicyLoadError(
23
+ "Cedar policy engine is required for enforcement. Install with: uv add cedarpy",
24
+ policies_dir="backend",
25
+ ) from exc
26
+
27
+ from cortexhub.policy.effects import Decision, Effect
28
+ from cortexhub.policy.loader import PolicyBundle
29
+ from cortexhub.policy.models import AuthorizationRequest
30
+
31
+ logger = structlog.get_logger(__name__)
32
+
33
+
34
+ class PolicyEvaluator:
35
+ """Evaluates authorization requests against Cedar policies.
36
+
37
+ Deterministic guarantee: Same input → same output, always.
38
+ Performance target: <0.5ms
39
+ """
40
+
41
+ def __init__(self, policy_bundle: PolicyBundle):
42
+ """Initialize policy evaluator.
43
+
44
+ Args:
45
+ policy_bundle: Loaded policy bundle (from loader.py)
46
+ """
47
+ self.policy_bundle = policy_bundle
48
+ self.default_behavior = policy_bundle.default_behavior
49
+
50
+ try:
51
+ logger.info(
52
+ "Cedar policy evaluator initialized",
53
+ version=policy_bundle.version,
54
+ default_behavior=self.default_behavior,
55
+ cedar_version="cedarpy",
56
+ )
57
+ except Exception as e:
58
+ raise PolicyLoadError(
59
+ f"Failed to initialize Cedar policy evaluator: {e}",
60
+ policies_dir="backend",
61
+ ) from e
62
+
63
+ def evaluate(self, request: AuthorizationRequest) -> Decision:
64
+ """Evaluate authorization request against policies.
65
+
66
+ Deterministic: Same request always produces same decision.
67
+ Performance target: <0.5ms
68
+
69
+ Args:
70
+ request: Authorization request to evaluate
71
+
72
+ Returns:
73
+ Decision (ALLOW/DENY/ESCALATE)
74
+ """
75
+ start_time = time.perf_counter()
76
+
77
+ try:
78
+ decision = self._evaluate_cedar(request)
79
+
80
+ latency_ms = (time.perf_counter() - start_time) * 1000
81
+
82
+ logger.info(
83
+ "Policy evaluated",
84
+ effect=decision.effect,
85
+ latency_ms=f"{latency_ms:.3f}",
86
+ trace_id=request.trace_id,
87
+ tool=request.action.name,
88
+ )
89
+
90
+ return decision
91
+
92
+ except Exception as e:
93
+ logger.error("Policy evaluation failed", error=str(e), trace_id=request.trace_id)
94
+ # Fail closed - deny on error
95
+ return Decision.deny(f"Policy evaluation error: {e}")
96
+
97
+ def _evaluate_cedar(self, request: AuthorizationRequest) -> Decision:
98
+ """Evaluate using real Cedar engine.
99
+
100
+ Args:
101
+ request: Authorization request
102
+
103
+ Returns:
104
+ Decision
105
+ """
106
+ principal_type = request.principal.type
107
+ principal_id = request.principal.id
108
+ action = request.action.type
109
+ resource_type = request.resource.type
110
+ resource_id = request.resource.id
111
+
112
+ cedar_request = {
113
+ "principal": f'{principal_type}::"{principal_id}"',
114
+ "action": f'Action::"{action}"',
115
+ "resource": f'{resource_type}::"{resource_id}"',
116
+ "context": self._json_safe(request.context),
117
+ }
118
+
119
+ result = cedar_module.is_authorized(
120
+ request=cedar_request,
121
+ policies=self.policy_bundle.policies,
122
+ entities=[],
123
+ schema=self.policy_bundle.schema or None,
124
+ )
125
+ policy_map = self.policy_bundle.metadata.get("policy_map", {}) if self.policy_bundle else {}
126
+ if os.getenv("CORTEXHUB_CEDAR_DEBUG", "").lower() in ("1", "true", "yes"):
127
+ logger.debug(
128
+ "Cedar evaluation",
129
+ decision=str(result.decision),
130
+ reasons=result.diagnostics.reasons,
131
+ policy_count=len(policy_map),
132
+ action=request.action.type,
133
+ resource=request.resource.type,
134
+ guardrails=self._json_safe(request.context.get("guardrails", {})),
135
+ redaction=self._json_safe(request.context.get("redaction", {})),
136
+ context_summary=self._summarize_context(request.context),
137
+ )
138
+
139
+ def _resolve_policy_metadata(policy_id: str | None) -> tuple[str | None, str | None, str | None]:
140
+ if policy_id:
141
+ meta = policy_map.get(policy_id, {})
142
+ return (
143
+ meta.get("policy_document_id") or policy_id,
144
+ meta.get("name"),
145
+ meta.get("effect"),
146
+ )
147
+ if len(policy_map) == 1:
148
+ only_id = next(iter(policy_map.keys()))
149
+ meta = policy_map.get(only_id, {})
150
+ return (
151
+ meta.get("policy_document_id") or only_id,
152
+ meta.get("name"),
153
+ meta.get("effect"),
154
+ )
155
+ return None, None, None
156
+
157
+ if result.decision == cedar_module.Decision.Allow:
158
+ reason = result.diagnostics.reasons[0] if result.diagnostics.reasons else None
159
+ policy_id, policy_name, policy_effect = _resolve_policy_metadata(reason)
160
+ return Decision.allow(
161
+ reasoning="Allowed by Cedar policy",
162
+ policy_id=policy_id,
163
+ policy_name=policy_name,
164
+ )
165
+ if result.decision == cedar_module.Decision.Deny:
166
+ if not result.diagnostics.reasons:
167
+ if self.default_behavior == "allow_and_log":
168
+ return Decision.allow(
169
+ f"Tool '{request.action.name}' allowed by default behavior",
170
+ policy_id="default",
171
+ )
172
+ if self.default_behavior == "deny_and_log":
173
+ return Decision.deny(
174
+ f"Tool '{request.action.name}' denied by default behavior (no matching policy)",
175
+ policy_id="default",
176
+ )
177
+ if self.default_behavior == "escalate":
178
+ return Decision.escalate(
179
+ f"Tool '{request.action.name}' requires approval (unknown tool)",
180
+ policy_id="default",
181
+ )
182
+ return Decision.deny("Invalid default behavior configuration", policy_id="error")
183
+ reason = result.diagnostics.reasons[0]
184
+ policy_id, policy_name, policy_effect = _resolve_policy_metadata(reason)
185
+ if policy_effect == "require_approval":
186
+ return Decision.escalate(
187
+ reasoning="Approval required by policy",
188
+ policy_id=policy_id,
189
+ policy_name=policy_name,
190
+ )
191
+ if self._should_escalate(request):
192
+ return Decision.escalate(
193
+ reasoning="High-risk operation requires approval",
194
+ policy_id=policy_id,
195
+ policy_name=policy_name,
196
+ )
197
+ return Decision.deny(
198
+ reasoning="Denied by CortexHub Policy",
199
+ policy_id=policy_id,
200
+ policy_name=policy_name,
201
+ )
202
+
203
+ # NoDecision: apply default behavior
204
+ if result.decision == cedar_module.Decision.NoDecision and self.default_behavior == "allow_and_log":
205
+ return Decision.allow(
206
+ f"Tool '{request.action.name}' allowed by default behavior",
207
+ policy_id="default",
208
+ )
209
+ if result.decision == cedar_module.Decision.NoDecision and self.default_behavior == "deny_and_log":
210
+ return Decision.deny(
211
+ f"Tool '{request.action.name}' denied by default behavior (no matching policy)",
212
+ policy_id="default",
213
+ )
214
+ if result.decision == cedar_module.Decision.NoDecision and self.default_behavior == "escalate":
215
+ return Decision.escalate(
216
+ f"Tool '{request.action.name}' requires approval (unknown tool)",
217
+ policy_id="default",
218
+ )
219
+ return Decision.deny("Invalid default behavior configuration", policy_id="error")
220
+
221
+ def _json_safe(self, value: Any) -> Any:
222
+ if value is None:
223
+ return None
224
+ if isinstance(value, datetime):
225
+ return value.isoformat()
226
+ if isinstance(value, dict):
227
+ cleaned: dict[str, Any] = {}
228
+ for key, val in value.items():
229
+ safe_val = self._json_safe(val)
230
+ if safe_val is not None:
231
+ cleaned[key] = safe_val
232
+ return cleaned
233
+ if isinstance(value, list):
234
+ return [item for item in (self._json_safe(item) for item in value) if item is not None]
235
+ return value
236
+
237
+ def _summarize_context(self, context: dict[str, Any]) -> dict[str, Any]:
238
+ def summarize(value: Any) -> Any:
239
+ if isinstance(value, dict):
240
+ return {key: summarize(val) for key, val in value.items()}
241
+ if isinstance(value, list):
242
+ return [summarize(item) for item in value]
243
+ return type(value).__name__
244
+
245
+ return summarize(context)
246
+
247
+ def _should_escalate(self, request: AuthorizationRequest) -> bool:
248
+ """Determine if a DENY should be escalated to approval (Cedar version).
249
+
250
+ Args:
251
+ request: Authorization request
252
+
253
+ Returns:
254
+ True if should escalate
255
+ """
256
+ tool_name = request.action.name
257
+ args = request.context.get("args", {})
258
+
259
+ # High-value refunds
260
+ if tool_name == "refund_payment":
261
+ amount = args.get("amount", 0)
262
+ return amount > 100
263
+
264
+ # Other escalation rules can be added here
265
+ return False
266
+
267
+