tollgate 1.0.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,91 @@
1
+ from typing import Any
2
+
3
+ from ..registry import ToolRegistry
4
+ from ..tower import ControlTower
5
+ from ..types import NormalizedToolCall, ToolRequest
6
+ from .base import TollgateInterceptor
7
+
8
+
9
+ class LangChainAdapter:
10
+ """Adapter for LangChain tools."""
11
+
12
+ def __init__(self, registry: ToolRegistry):
13
+ self.registry = registry
14
+
15
+ def normalize(self, tool_call: Any) -> NormalizedToolCall:
16
+ # tool_call is expected to be (tool, tool_input) or (tool, tool_input, kwargs)
17
+ if len(tool_call) == 3:
18
+ tool, tool_input, kwargs = tool_call
19
+ else:
20
+ tool, tool_input = tool_call
21
+ kwargs = {}
22
+
23
+ tool_name = getattr(tool, "name", str(tool))
24
+ registry_key = f"langchain:{tool_name}"
25
+
26
+ effect, resource_type, manifest_version = self.registry.resolve_tool(
27
+ registry_key
28
+ )
29
+
30
+ metadata = kwargs.get("metadata", {})
31
+
32
+ params = tool_input if isinstance(tool_input, dict) else {"input": tool_input}
33
+
34
+ request = ToolRequest(
35
+ tool="langchain",
36
+ action=tool_name,
37
+ resource_type=resource_type,
38
+ effect=effect,
39
+ params=params,
40
+ metadata=metadata,
41
+ manifest_version=manifest_version,
42
+ )
43
+
44
+ async def _exec_async():
45
+ if hasattr(tool, "ainvoke"):
46
+ return await tool.ainvoke(tool_input)
47
+ return await tool.arun(tool_input)
48
+
49
+ def _exec_sync():
50
+ if hasattr(tool, "invoke"):
51
+ return tool.invoke(tool_input)
52
+ return tool.run(tool_input)
53
+
54
+ return NormalizedToolCall(
55
+ request=request, exec_async=_exec_async, exec_sync=_exec_sync
56
+ )
57
+
58
+
59
+ class GuardedLangChainTool:
60
+ """A wrapper for LangChain tools that enforces Tollgate gating."""
61
+
62
+ def __init__(self, tool: Any, interceptor: TollgateInterceptor):
63
+ self.tool = tool
64
+ self.interceptor = interceptor
65
+ self.name = tool.name
66
+ self.description = tool.description
67
+
68
+ async def ainvoke(self, tool_input, agent_ctx=None, intent=None, **kwargs):
69
+ if agent_ctx is None or intent is None:
70
+ return await self.tool.ainvoke(tool_input, **kwargs)
71
+
72
+ return await self.interceptor.intercept_async(
73
+ agent_ctx, intent, (self.tool, tool_input, kwargs)
74
+ )
75
+
76
+ def invoke(self, tool_input, agent_ctx=None, intent=None, **kwargs):
77
+ if agent_ctx is None or intent is None:
78
+ return self.tool.invoke(tool_input, **kwargs)
79
+
80
+ return self.interceptor.intercept(
81
+ agent_ctx, intent, (self.tool, tool_input, kwargs)
82
+ )
83
+
84
+
85
+ def guard_tools(
86
+ tools: list[Any], tower: ControlTower, registry: ToolRegistry
87
+ ) -> list[Any]:
88
+ """Wrap a list of LangChain tools with Tollgate."""
89
+ adapter = LangChainAdapter(registry)
90
+ interceptor = TollgateInterceptor(tower, adapter)
91
+ return [GuardedLangChainTool(t, interceptor) for t in tools]
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+ import json
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from ..registry import ToolRegistry
7
+ from ..tower import ControlTower
8
+ from ..types import AgentContext, Intent, NormalizedToolCall, ToolRequest
9
+ from .base import TollgateInterceptor
10
+
11
+
12
+ class OpenAIAdapter:
13
+ """Adapter for OpenAI function/tool calls."""
14
+
15
+ def __init__(self, registry: ToolRegistry, tool_map: dict[str, Callable]):
16
+ self.registry = registry
17
+ self.tool_map = tool_map
18
+
19
+ def normalize(self, tool_call: Any) -> NormalizedToolCall:
20
+ # tool_call is expected to be a dict with 'name' and 'arguments'
21
+ # OR (tool_call_dict, kwargs)
22
+ if isinstance(tool_call, tuple) and len(tool_call) == 2:
23
+ tc_dict, kwargs = tool_call
24
+ else:
25
+ tc_dict = tool_call
26
+ kwargs = {}
27
+
28
+ tool_name = tc_dict.get("function", {}).get("name") or tc_dict.get("name")
29
+ args_str = tc_dict.get("function", {}).get("arguments") or tc_dict.get(
30
+ "arguments"
31
+ )
32
+ args = json.loads(args_str) if isinstance(args_str, str) else args_str
33
+
34
+ registry_key = f"openai:{tool_name}"
35
+ effect, resource_type, manifest_version = self.registry.resolve_tool(
36
+ registry_key
37
+ )
38
+
39
+ metadata = kwargs.get("metadata", {})
40
+
41
+ request = ToolRequest(
42
+ tool="openai",
43
+ action=tool_name,
44
+ resource_type=resource_type,
45
+ effect=effect,
46
+ params=args,
47
+ metadata=metadata,
48
+ manifest_version=manifest_version,
49
+ )
50
+
51
+ func = self.tool_map[tool_name]
52
+
53
+ async def _exec_async():
54
+ if asyncio.iscoroutinefunction(func):
55
+ return await func(**args)
56
+ return func(**args)
57
+
58
+ def _exec_sync():
59
+ return func(**args)
60
+
61
+ return NormalizedToolCall(
62
+ request=request, exec_async=_exec_async, exec_sync=_exec_sync
63
+ )
64
+
65
+
66
+ class OpenAIToolRunner:
67
+ """Helper to run OpenAI tool calls through Tollgate."""
68
+
69
+ def __init__(self, tower: ControlTower, registry: ToolRegistry):
70
+ self.tower = tower
71
+ self.registry = registry
72
+
73
+ async def run_async(
74
+ self,
75
+ tool_calls: list[Any],
76
+ tool_map: dict[str, Callable],
77
+ agent_ctx: AgentContext,
78
+ intent: Intent,
79
+ ) -> list[Any]:
80
+ adapter = OpenAIAdapter(self.registry, tool_map)
81
+ interceptor = TollgateInterceptor(self.tower, adapter)
82
+
83
+ results = []
84
+ for tc in tool_calls:
85
+ result = await interceptor.intercept_async(agent_ctx, intent, tc)
86
+ results.append(result)
87
+ return results
tollgate/policy.py ADDED
@@ -0,0 +1,152 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+ from typing import Any, Protocol
4
+
5
+ import yaml
6
+
7
+ from .types import AgentContext, Decision, DecisionType, Effect, Intent, ToolRequest
8
+
9
+
10
+ class PolicyEvaluator(Protocol):
11
+ """Protocol for policy evaluation."""
12
+
13
+ def evaluate(
14
+ self, agent_ctx: AgentContext, intent: Intent, tool_request: ToolRequest
15
+ ) -> Decision:
16
+ """Evaluate a tool request against the policy."""
17
+ ...
18
+
19
+
20
+ class YamlPolicyEvaluator:
21
+ """YAML-based policy evaluator with safe defaults."""
22
+
23
+ def __init__(
24
+ self,
25
+ policy_path: str | Path,
26
+ default_if_unknown: DecisionType = DecisionType.DENY,
27
+ ):
28
+ """Load and validate policy from YAML file."""
29
+ self.path = Path(policy_path)
30
+ with self.path.open("r") as f:
31
+ content = f.read()
32
+ self.data = yaml.safe_load(content)
33
+ self.version = self.data.get(
34
+ "version", hashlib.sha256(content.encode()).hexdigest()[:8]
35
+ )
36
+
37
+ self.rules = self.data.get("rules", [])
38
+ self.default_if_unknown = default_if_unknown
39
+ self._validate_rules()
40
+
41
+ def _validate_rules(self):
42
+ """Basic validation of rule structure."""
43
+ for i, rule in enumerate(self.rules):
44
+ if "decision" not in rule:
45
+ raise ValueError(f"Rule at index {i} is missing 'decision' key")
46
+ try:
47
+ DecisionType(rule["decision"])
48
+ except ValueError:
49
+ raise ValueError(
50
+ f"Invalid decision '{rule['decision']}' in rule at index {i}"
51
+ ) from None
52
+
53
+ def evaluate(
54
+ self, agent_ctx: AgentContext, intent: Intent, tool_request: ToolRequest
55
+ ) -> Decision:
56
+ """Evaluate a tool request against loaded rules."""
57
+ # Principle 2: Safe Defaults for unknown effect/resource
58
+ if tool_request.effect == Effect.UNKNOWN:
59
+ return Decision(
60
+ decision=self.default_if_unknown,
61
+ reason="Unknown tool effect. Safe default applied.",
62
+ policy_version=self.version,
63
+ )
64
+
65
+ for rule in self.rules:
66
+ if self._matches(rule, agent_ctx, intent, tool_request):
67
+ # Principle 3: Trusted Attributes
68
+ # If ALLOW, ensure effect and resource_type are from registry (trusted)
69
+ decision = DecisionType(rule["decision"])
70
+ if decision == DecisionType.ALLOW and not tool_request.manifest_version:
71
+ return Decision(
72
+ decision=DecisionType.ASK,
73
+ reason=(
74
+ "ALLOW decision requires trusted tool metadata "
75
+ "from registry."
76
+ ),
77
+ policy_version=self.version,
78
+ )
79
+
80
+ return Decision(
81
+ decision=decision,
82
+ reason=rule.get("reason", "Rule matched"),
83
+ policy_id=rule.get("id"),
84
+ policy_version=self.version,
85
+ metadata=rule.get("metadata", {}),
86
+ )
87
+
88
+ return Decision(
89
+ decision=DecisionType.DENY,
90
+ reason="No matching policy rule found. Defaulting to DENY.",
91
+ policy_version=self.version,
92
+ )
93
+
94
+ def _matches(
95
+ self,
96
+ rule: dict,
97
+ agent_ctx: AgentContext,
98
+ intent: Intent,
99
+ req: ToolRequest,
100
+ ) -> bool:
101
+ # Match tool, action, resource_type, effect
102
+ if "tool" in rule and rule["tool"] != req.tool:
103
+ return False
104
+ if "action" in rule and rule["action"] != req.action:
105
+ return False
106
+ if "resource_type" in rule and rule["resource_type"] != req.resource_type:
107
+ return False
108
+ if "effect" in rule and rule["effect"] != req.effect.value:
109
+ return False
110
+
111
+ # Match Agent Context
112
+ if "agent" in rule:
113
+ for key, expected_val in rule["agent"].items():
114
+ if getattr(agent_ctx, key, None) != expected_val:
115
+ return False
116
+
117
+ # Match Intent
118
+ if "intent" in rule:
119
+ for key, expected_val in rule["intent"].items():
120
+ if getattr(intent, key, None) != expected_val:
121
+ return False
122
+
123
+ # Match metadata conditions (Untrusted)
124
+ if "when" in rule:
125
+ for key, condition in rule["when"].items():
126
+ val = req.metadata.get(key)
127
+ if not self._check_condition(val, condition):
128
+ return False
129
+
130
+ return True
131
+
132
+ def _check_condition(self, val: Any, condition: Any) -> bool:
133
+ """Check a single condition against a value."""
134
+ if isinstance(condition, dict):
135
+ for op, target in condition.items():
136
+ if val is None and op in (">", ">=", "<", "<="):
137
+ return False
138
+
139
+ if op == "==" and val != target:
140
+ return False
141
+ if op == "!=" and val == target:
142
+ return False
143
+ if op == ">" and not (val > target):
144
+ return False
145
+ if op == ">=" and not (val >= target):
146
+ return False
147
+ if op == "<" and not (val < target):
148
+ return False
149
+ if op == "<=" and not (val <= target):
150
+ return False
151
+ return True
152
+ return val == condition
tollgate/registry.py ADDED
@@ -0,0 +1,58 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+
6
+ from .types import Effect
7
+
8
+
9
+ class ToolRegistry:
10
+ """A registry of trusted tool metadata."""
11
+
12
+ def __init__(self, manifest_path: str | Path):
13
+ self.path = Path(manifest_path)
14
+ if not self.path.exists():
15
+ raise FileNotFoundError(f"Manifest file not found: {manifest_path}")
16
+
17
+ with self.path.open("r") as f:
18
+ content = f.read()
19
+ self.data = yaml.safe_load(content)
20
+ if not self.data:
21
+ self.data = {}
22
+ # Use content hash as manifest version if not provided
23
+ self.version = str(
24
+ self.data.get(
25
+ "version", hashlib.sha256(content.encode()).hexdigest()[:8]
26
+ )
27
+ )
28
+ self.tools = self.data.get("tools", {})
29
+ self._validate_manifest()
30
+
31
+ def _validate_manifest(self):
32
+ """Basic validation of manifest structure."""
33
+ if not isinstance(self.tools, dict):
34
+ raise ValueError("Manifest 'tools' must be a dictionary.")
35
+
36
+ for key, entry in self.tools.items():
37
+ if not isinstance(entry, dict):
38
+ raise ValueError(f"Tool entry '{key}' must be a dictionary.")
39
+ if "effect" in entry:
40
+ try:
41
+ Effect(entry["effect"])
42
+ except ValueError as e:
43
+ raise ValueError(
44
+ f"Invalid effect '{entry['effect']}' for tool '{key}'."
45
+ ) from e
46
+
47
+ def resolve_tool(self, tool_key: str) -> tuple[Effect, str, str | None]:
48
+ """
49
+ Resolve tool key to (effect, resource_type, manifest_version).
50
+ Returns (UNKNOWN, "unknown", None) if not found.
51
+ """
52
+ meta = self.tools.get(tool_key)
53
+ if not meta:
54
+ return Effect.UNKNOWN, "unknown", None
55
+
56
+ effect = Effect(meta.get("effect", "unknown"))
57
+ resource_type = meta.get("resource_type", "unknown")
58
+ return effect, resource_type, self.version
tollgate/tower.py ADDED
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import uuid
3
+ from collections.abc import Awaitable, Callable
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from .approvals import Approver, compute_request_hash
8
+ from .audit import AuditSink
9
+ from .exceptions import (
10
+ TollgateApprovalDenied,
11
+ TollgateDeferred,
12
+ TollgateDenied,
13
+ )
14
+ from .policy import PolicyEvaluator
15
+ from .types import (
16
+ AgentContext,
17
+ ApprovalOutcome,
18
+ AuditEvent,
19
+ Decision,
20
+ DecisionType,
21
+ Intent,
22
+ Outcome,
23
+ ToolRequest,
24
+ )
25
+
26
+
27
+ class ControlTower:
28
+ """Async-first control tower for tool execution enforcement."""
29
+
30
+ def __init__(
31
+ self,
32
+ policy: PolicyEvaluator,
33
+ approver: Approver,
34
+ audit: AuditSink,
35
+ redact_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
36
+ ):
37
+ self.policy = policy
38
+ self.approver = approver
39
+ self.audit = audit
40
+ self.redact_fn = redact_fn or self._default_redact
41
+
42
+ @staticmethod
43
+ def _default_redact(params: dict[str, Any]) -> dict[str, Any]:
44
+ """Redact sensitive keys by default."""
45
+ sensitive_keys = {
46
+ "password",
47
+ "token",
48
+ "secret",
49
+ "authorization",
50
+ "api_key",
51
+ "key",
52
+ }
53
+ return {
54
+ k: ("[REDACTED]" if k.lower() in sensitive_keys else v)
55
+ for k, v in params.items()
56
+ }
57
+
58
+ async def execute_async(
59
+ self,
60
+ agent_ctx: AgentContext,
61
+ intent: Intent,
62
+ tool_request: ToolRequest,
63
+ exec_async: Callable[[], Awaitable[Any]],
64
+ ) -> Any:
65
+ """
66
+ Evaluate and execute a tool call asynchronously.
67
+ """
68
+ correlation_id = str(uuid.uuid4())
69
+ request_hash = compute_request_hash(agent_ctx, intent, tool_request)
70
+
71
+ # 1. Evaluate Policy
72
+ decision = self.policy.evaluate(agent_ctx, intent, tool_request)
73
+
74
+ # 2. Handle DENY
75
+ if decision.decision == DecisionType.DENY:
76
+ self._log(
77
+ correlation_id,
78
+ request_hash,
79
+ agent_ctx,
80
+ intent,
81
+ tool_request,
82
+ decision,
83
+ Outcome.BLOCKED,
84
+ )
85
+ raise TollgateDenied(decision.reason)
86
+
87
+ # 3. Handle ASK
88
+ if decision.decision == DecisionType.ASK:
89
+ outcome = await self.approver.request_approval_async(
90
+ agent_ctx, intent, tool_request, request_hash, decision.reason
91
+ )
92
+
93
+ if outcome == ApprovalOutcome.DEFERRED:
94
+ # Audit the deferral
95
+ self._log(
96
+ correlation_id,
97
+ request_hash,
98
+ agent_ctx,
99
+ intent,
100
+ tool_request,
101
+ decision,
102
+ Outcome.BLOCKED, # Deferral is a temporary block
103
+ )
104
+ raise TollgateDeferred("pending")
105
+
106
+ if outcome != ApprovalOutcome.APPROVED:
107
+ final_outcome = (
108
+ Outcome.TIMEOUT
109
+ if outcome == ApprovalOutcome.TIMEOUT
110
+ else Outcome.APPROVAL_DENIED
111
+ )
112
+ self._log(
113
+ correlation_id,
114
+ request_hash,
115
+ agent_ctx,
116
+ intent,
117
+ tool_request,
118
+ decision,
119
+ final_outcome,
120
+ )
121
+ raise TollgateApprovalDenied(f"Approval failed: {outcome.value}")
122
+
123
+ # 4. Execute tool
124
+ result = None
125
+ outcome = Outcome.EXECUTED
126
+ try:
127
+ result = await exec_async()
128
+ except Exception as e:
129
+ outcome = Outcome.FAILED
130
+ result_summary = f"{type(e).__name__}: {str(e)}"
131
+ self._log(
132
+ correlation_id,
133
+ request_hash,
134
+ agent_ctx,
135
+ intent,
136
+ tool_request,
137
+ decision,
138
+ outcome,
139
+ result_summary=result_summary,
140
+ )
141
+ raise
142
+
143
+ # 5. Final Audit
144
+ result_summary = self._truncate_result(result)
145
+ self._log(
146
+ correlation_id,
147
+ request_hash,
148
+ agent_ctx,
149
+ intent,
150
+ tool_request,
151
+ decision,
152
+ outcome,
153
+ result_summary=result_summary,
154
+ )
155
+
156
+ return result
157
+
158
+ def execute(
159
+ self,
160
+ agent_ctx: AgentContext,
161
+ intent: Intent,
162
+ tool_request: ToolRequest,
163
+ exec_sync: Callable[[], Any],
164
+ ) -> Any:
165
+ """Sync wrapper for execute_async. Safe only if no event loop is running."""
166
+ try:
167
+ loop = asyncio.get_running_loop()
168
+ if loop.is_running():
169
+ raise RuntimeError(
170
+ "execute() called from within a running event loop. "
171
+ "Use execute_async() instead."
172
+ )
173
+ except RuntimeError:
174
+ pass
175
+
176
+ async def _exec():
177
+ return exec_sync()
178
+
179
+ return asyncio.run(self.execute_async(agent_ctx, intent, tool_request, _exec))
180
+
181
+ def _log(
182
+ self,
183
+ correlation_id: str,
184
+ request_hash: str,
185
+ agent: AgentContext,
186
+ intent: Intent,
187
+ req: ToolRequest,
188
+ decision: Decision,
189
+ outcome: Outcome,
190
+ approval_id: str | None = None,
191
+ result_summary: str | None = None,
192
+ ):
193
+ # Redact params before logging
194
+ redacted_req = ToolRequest(
195
+ tool=req.tool,
196
+ action=req.action,
197
+ resource_type=req.resource_type,
198
+ effect=req.effect,
199
+ params=self.redact_fn(req.params),
200
+ metadata=req.metadata,
201
+ manifest_version=req.manifest_version,
202
+ )
203
+
204
+ event = AuditEvent(
205
+ timestamp=datetime.now(timezone.utc).isoformat(),
206
+ correlation_id=correlation_id,
207
+ request_hash=request_hash,
208
+ agent=agent,
209
+ intent=intent,
210
+ tool_request=redacted_req,
211
+ decision=decision,
212
+ outcome=outcome,
213
+ approval_id=approval_id,
214
+ result_summary=result_summary,
215
+ policy_version=decision.policy_version,
216
+ manifest_version=req.manifest_version,
217
+ )
218
+ self.audit.emit(event)
219
+
220
+ def _truncate_result(self, result: Any, max_chars: int = 200) -> str | None:
221
+ if result is None:
222
+ return None
223
+ s = str(result)
224
+ return s[:max_chars] + "..." if len(s) > max_chars else s