tollgate 1.0.3__py3-none-any.whl → 1.0.5__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.
tollgate/__init__.py CHANGED
@@ -1,5 +1,4 @@
1
1
  from .approvals import (
2
- ApprovalOutcome,
3
2
  ApprovalStore,
4
3
  Approver,
5
4
  AsyncQueueApprover,
@@ -15,23 +14,26 @@ from .exceptions import (
15
14
  TollgateDenied,
16
15
  TollgateError,
17
16
  )
17
+ from .grants import GrantStore, InMemoryGrantStore
18
18
  from .helpers import guard, wrap_tool
19
19
  from .policy import PolicyEvaluator, YamlPolicyEvaluator
20
20
  from .registry import ToolRegistry
21
21
  from .tower import ControlTower
22
22
  from .types import (
23
23
  AgentContext,
24
+ ApprovalOutcome,
24
25
  AuditEvent,
25
26
  Decision,
26
27
  DecisionType,
27
28
  Effect,
29
+ Grant,
28
30
  Intent,
29
31
  NormalizedToolCall,
30
32
  Outcome,
31
33
  ToolRequest,
32
34
  )
33
35
 
34
- __version__ = "1.0.3"
36
+ __version__ = "1.0.5"
35
37
 
36
38
  __all__ = [
37
39
  "ControlTower",
@@ -42,6 +44,8 @@ __all__ = [
42
44
  "Decision",
43
45
  "DecisionType",
44
46
  "Effect",
47
+ "Grant",
48
+ "GrantStore",
45
49
  "AuditEvent",
46
50
  "Outcome",
47
51
  "ApprovalOutcome",
@@ -57,6 +61,7 @@ __all__ = [
57
61
  "ToolRegistry",
58
62
  "PolicyEvaluator",
59
63
  "YamlPolicyEvaluator",
64
+ "InMemoryGrantStore",
60
65
  "TollgateError",
61
66
  "TollgateDenied",
62
67
  "TollgateApprovalDenied",
tollgate/grants.py ADDED
@@ -0,0 +1,149 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from .types import AgentContext, Grant, ToolRequest
6
+
7
+
8
+ @runtime_checkable
9
+ class GrantStore(Protocol):
10
+ """Protocol for grant storage backends.
11
+
12
+ Implement this protocol to use a custom storage backend (Redis, SQLite, etc.).
13
+
14
+ Example Redis implementation:
15
+
16
+ class RedisGrantStore:
17
+ def __init__(self, redis_client):
18
+ self.redis = redis_client
19
+
20
+ async def create_grant(self, grant: Grant) -> str:
21
+ await self.redis.hset(f"grant:{grant.id}", mapping=grant.to_dict())
22
+ await self.redis.expireat(f"grant:{grant.id}", int(grant.expires_at))
23
+ return grant.id
24
+
25
+ async def find_matching_grant(self, agent_ctx, tool_request) -> Grant | None:
26
+ # Implement matching logic with Redis SCAN or secondary indexes
27
+ ...
28
+
29
+ All methods must be async. The InMemoryGrantStore serves as the reference implementation.
30
+ """
31
+
32
+ async def create_grant(self, grant: Grant) -> str:
33
+ ...
34
+
35
+ async def find_matching_grant(
36
+ self, agent_ctx: AgentContext, tool_request: ToolRequest
37
+ ) -> Grant | None:
38
+ ...
39
+
40
+ async def revoke_grant(self, grant_id: str) -> bool:
41
+ ...
42
+
43
+ async def list_active_grants(self, agent_id: str | None = None) -> list[Grant]:
44
+ ...
45
+
46
+ async def cleanup_expired(self) -> int:
47
+ ...
48
+
49
+ async def get_usage_count(self, grant_id: str) -> int:
50
+ ...
51
+
52
+
53
+ class InMemoryGrantStore:
54
+ """In-memory store for action grants with thread-safe matching logic."""
55
+
56
+ def __init__(self):
57
+ self._grants: dict[str, Grant] = {}
58
+ self._usage_counts: dict[str, int] = {}
59
+ self._lock = asyncio.Lock()
60
+
61
+ async def create_grant(self, grant: Grant) -> str:
62
+ """Store a new grant."""
63
+ async with self._lock:
64
+ self._grants[grant.id] = grant
65
+ self._usage_counts[grant.id] = 0
66
+ return grant.id
67
+
68
+ async def find_matching_grant(
69
+ self, agent_ctx: AgentContext, tool_request: ToolRequest
70
+ ) -> Grant | None:
71
+ """Find a non-expired grant that matches the request."""
72
+ now = time.time()
73
+ async with self._lock:
74
+ for grant in self._grants.values():
75
+ # 1. Skip expired
76
+ if grant.expires_at <= now:
77
+ continue
78
+
79
+ # 2. Match agent_id
80
+ if grant.agent_id is not None and grant.agent_id != agent_ctx.agent_id:
81
+ continue
82
+
83
+ # 3. Match effect
84
+ if grant.effect is not None and grant.effect != tool_request.effect:
85
+ continue
86
+
87
+ # 4. Match tool (exact or prefix with *)
88
+ if grant.tool is not None:
89
+ if grant.tool.endswith("*"):
90
+ prefix = grant.tool[:-1]
91
+ if not tool_request.tool.startswith(prefix):
92
+ continue
93
+ elif grant.tool != tool_request.tool:
94
+ continue
95
+
96
+ # 5. Match action
97
+ if grant.action is not None and grant.action != tool_request.action:
98
+ continue
99
+
100
+ # 6. Match resource_type
101
+ if (
102
+ grant.resource_type is not None
103
+ and grant.resource_type != tool_request.resource_type
104
+ ):
105
+ continue
106
+
107
+ # Match found! Increment usage count
108
+ self._usage_counts[grant.id] += 1
109
+ return grant
110
+ return None
111
+
112
+ async def get_usage_count(self, grant_id: str) -> int:
113
+ """Get the number of times a grant has been used."""
114
+ async with self._lock:
115
+ return self._usage_counts.get(grant_id, 0)
116
+
117
+ async def revoke_grant(self, grant_id: str) -> bool:
118
+ """Remove a grant by ID."""
119
+ async with self._lock:
120
+ if grant_id in self._grants:
121
+ del self._grants[grant_id]
122
+ if grant_id in self._usage_counts:
123
+ del self._usage_counts[grant_id]
124
+ return True
125
+ return False
126
+
127
+ async def list_active_grants(self, agent_id: str | None = None) -> list[Grant]:
128
+ """List all non-expired grants, optionally filtered by agent."""
129
+ now = time.time()
130
+ async with self._lock:
131
+ active = []
132
+ for grant in self._grants.values():
133
+ if grant.expires_at > now:
134
+ if agent_id is None or grant.agent_id == agent_id:
135
+ active.append(grant)
136
+ return active
137
+
138
+ async def cleanup_expired(self) -> int:
139
+ """Remove all expired grants from the store."""
140
+ now = time.time()
141
+ async with self._lock:
142
+ to_remove = [
143
+ gid for gid, g in self._grants.items() if g.expires_at <= now
144
+ ]
145
+ for gid in to_remove:
146
+ del self._grants[gid]
147
+ if gid in self._usage_counts:
148
+ del self._usage_counts[gid]
149
+ return len(to_remove)
tollgate/tower.py CHANGED
@@ -11,6 +11,7 @@ from .exceptions import (
11
11
  TollgateDeferred,
12
12
  TollgateDenied,
13
13
  )
14
+ from .grants import GrantStore
14
15
  from .policy import PolicyEvaluator
15
16
  from .types import (
16
17
  AgentContext,
@@ -32,11 +33,13 @@ class ControlTower:
32
33
  policy: PolicyEvaluator,
33
34
  approver: Approver,
34
35
  audit: AuditSink,
36
+ grant_store: GrantStore | None = None,
35
37
  redact_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
36
38
  ):
37
39
  self.policy = policy
38
40
  self.approver = approver
39
41
  self.audit = audit
42
+ self.grant_store = grant_store
40
43
  self.redact_fn = redact_fn or self._default_redact
41
44
 
42
45
  @staticmethod
@@ -86,6 +89,26 @@ class ControlTower:
86
89
 
87
90
  # 3. Handle ASK
88
91
  if decision.decision == DecisionType.ASK:
92
+ # 3.1 Check Grants
93
+ if self.grant_store:
94
+ matching_grant = await self.grant_store.find_matching_grant(
95
+ agent_ctx, tool_request
96
+ )
97
+ if matching_grant:
98
+ # Grant found! Proceed to execution without asking approver
99
+ result = await self._execute_and_log(
100
+ correlation_id,
101
+ request_hash,
102
+ agent_ctx,
103
+ intent,
104
+ tool_request,
105
+ decision,
106
+ exec_async,
107
+ grant_id=matching_grant.id,
108
+ )
109
+ return result
110
+
111
+ # 3.2 Request Approval if no grant found
89
112
  outcome = await self.approver.request_approval_async(
90
113
  agent_ctx, intent, tool_request, request_hash, decision.reason
91
114
  )
@@ -120,14 +143,35 @@ class ControlTower:
120
143
  )
121
144
  raise TollgateApprovalDenied(f"Approval failed: {outcome.value}")
122
145
 
123
- # 4. Execute tool
146
+ # 4. Execute tool (Policy ALLOW or Approval APPROVED)
147
+ return await self._execute_and_log(
148
+ correlation_id,
149
+ request_hash,
150
+ agent_ctx,
151
+ intent,
152
+ tool_request,
153
+ decision,
154
+ exec_async,
155
+ )
156
+
157
+ async def _execute_and_log(
158
+ self,
159
+ correlation_id: str,
160
+ request_hash: str,
161
+ agent_ctx: AgentContext,
162
+ intent: Intent,
163
+ tool_request: ToolRequest,
164
+ decision: Decision,
165
+ exec_async: Callable[[], Awaitable[Any]],
166
+ grant_id: str | None = None,
167
+ ) -> Any:
168
+ """Internal helper to execute tool and log result."""
124
169
  result = None
125
170
  outcome = Outcome.EXECUTED
126
171
  try:
127
172
  result = await exec_async()
128
173
  except Exception as e:
129
174
  outcome = Outcome.FAILED
130
- # Security: Sanitize exception message to avoid info disclosure
131
175
  result_summary = self._sanitize_exception(e)
132
176
  self._log(
133
177
  correlation_id,
@@ -137,11 +181,12 @@ class ControlTower:
137
181
  tool_request,
138
182
  decision,
139
183
  outcome,
184
+ grant_id=grant_id,
140
185
  result_summary=result_summary,
141
186
  )
142
187
  raise
143
188
 
144
- # 5. Final Audit
189
+ # Final Audit
145
190
  result_summary = self._truncate_result(result)
146
191
  self._log(
147
192
  correlation_id,
@@ -151,6 +196,7 @@ class ControlTower:
151
196
  tool_request,
152
197
  decision,
153
198
  outcome,
199
+ grant_id=grant_id,
154
200
  result_summary=result_summary,
155
201
  )
156
202
 
@@ -177,7 +223,9 @@ class ControlTower:
177
223
  async def _exec():
178
224
  return exec_sync()
179
225
 
180
- return asyncio.run(self.execute_async(agent_ctx, intent, tool_request, _exec))
226
+ return asyncio.run(
227
+ self.execute_async(agent_ctx, intent, tool_request, _exec)
228
+ )
181
229
 
182
230
  def _log(
183
231
  self,
@@ -189,6 +237,7 @@ class ControlTower:
189
237
  decision: Decision,
190
238
  outcome: Outcome,
191
239
  approval_id: str | None = None,
240
+ grant_id: str | None = None,
192
241
  result_summary: str | None = None,
193
242
  ):
194
243
  # Redact params before logging
@@ -212,47 +261,20 @@ class ControlTower:
212
261
  decision=decision,
213
262
  outcome=outcome,
214
263
  approval_id=approval_id,
264
+ grant_id=grant_id,
215
265
  result_summary=result_summary,
216
266
  policy_version=decision.policy_version,
217
267
  manifest_version=req.manifest_version,
218
268
  )
219
269
  self.audit.emit(event)
220
270
 
271
+ def _sanitize_exception(self, e: Exception) -> str:
272
+ """Sanitize exception message to avoid leaking sensitive data."""
273
+ # Only include the exception type and a generic message for security
274
+ return f"{type(e).__name__}: Execution failed"
275
+
221
276
  def _truncate_result(self, result: Any, max_chars: int = 200) -> str | None:
222
277
  if result is None:
223
278
  return None
224
279
  s = str(result)
225
280
  return s[:max_chars] + "..." if len(s) > max_chars else s
226
-
227
- def _sanitize_exception(self, e: Exception, max_chars: int = 100) -> str:
228
- """
229
- Sanitize exception message for audit logging.
230
-
231
- Removes potentially sensitive information like file paths, stack traces,
232
- and internal details while preserving the exception type.
233
- """
234
- exc_type = type(e).__name__
235
-
236
- # For common safe exceptions, include a truncated message
237
- safe_exceptions = {
238
- "ValueError",
239
- "TypeError",
240
- "KeyError",
241
- "IndexError",
242
- "AttributeError",
243
- "RuntimeError",
244
- }
245
-
246
- if exc_type in safe_exceptions:
247
- msg = str(e)
248
- # Remove file paths (Unix and Windows style)
249
- import re
250
-
251
- msg = re.sub(r"['\"]?(/[^\s'\"]+|[A-Za-z]:\\[^\s'\"]+)['\"]?", "[PATH]", msg)
252
- # Truncate to avoid leaking too much info
253
- if len(msg) > max_chars:
254
- msg = msg[:max_chars] + "..."
255
- return f"{exc_type}: {msg}"
256
-
257
- # For unknown exceptions, only log the type
258
- return f"{exc_type}: [details redacted]"
tollgate/types.py CHANGED
@@ -1,3 +1,4 @@
1
+ import uuid
1
2
  from collections.abc import Awaitable, Callable
2
3
  from dataclasses import asdict, dataclass, field
3
4
  from enum import Enum
@@ -92,6 +93,28 @@ class Decision:
92
93
  return d
93
94
 
94
95
 
96
+ @dataclass(frozen=True)
97
+ class Grant:
98
+ """A grant that allows bypassing human approval for specific actions."""
99
+
100
+ agent_id: str | None # None = any agent
101
+ effect: Effect | None # None = any effect
102
+ tool: str | None # None = any tool, supports prefix like "mcp:*"
103
+ action: str | None # None = any action
104
+ resource_type: str | None # None = any resource
105
+ expires_at: float # Unix timestamp
106
+ granted_by: str
107
+ created_at: float
108
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
109
+ reason: str | None = None
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ d = asdict(self)
113
+ if self.effect:
114
+ d["effect"] = self.effect.value
115
+ return d
116
+
117
+
95
118
  @dataclass(frozen=True)
96
119
  class AuditEvent:
97
120
  timestamp: str
@@ -103,6 +126,7 @@ class AuditEvent:
103
126
  decision: Decision
104
127
  outcome: Outcome
105
128
  approval_id: str | None = None
129
+ grant_id: str | None = None
106
130
  result_summary: str | None = None
107
131
  policy_version: str | None = None
108
132
  manifest_version: str | None = None
@@ -118,6 +142,7 @@ class AuditEvent:
118
142
  "decision": self.decision.to_dict(),
119
143
  "outcome": self.outcome.value,
120
144
  "approval_id": self.approval_id,
145
+ "grant_id": self.grant_id,
121
146
  "result_summary": self.result_summary,
122
147
  "policy_version": self.policy_version,
123
148
  "manifest_version": self.manifest_version,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tollgate
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Runtime enforcement layer for AI agent tool calls using Identity + Intent + Policy
5
5
  Author: Tollgate Maintainers
6
6
  License-Expression: Apache-2.0
@@ -77,6 +77,30 @@ Runtime enforcement layer for AI agent tool calls using **Identity + Intent + Po
77
77
 
78
78
  ## 🚀 v1 Integrations
79
79
 
80
+ ### 🎟️ Session Grants
81
+ Grants allow you to pre-authorize specific actions for an agent session, bypassing human-in-the-loop approvals for repetitive or low-risk tasks.
82
+
83
+ ```python
84
+ from tollgate import Grant, InMemoryGrantStore, Effect
85
+
86
+ # 1. Setup a grant store
87
+ grant_store = InMemoryGrantStore()
88
+ tower = ControlTower(..., grant_store=grant_store)
89
+
90
+ # 2. Issue a grant (e.g., after initial human approval)
91
+ grant = Grant(
92
+ agent_id="my-agent",
93
+ effect=Effect.WRITE,
94
+ tool="mcp:*", # Wildcard prefix: matches any MCP tool (e.g., "mcp:server.write")
95
+ action=None, # Wildcard: matches any action
96
+ resource_type=None,
97
+ expires_at=time.time() + 3600, # Valid for 1 hour
98
+ granted_by="admin-user",
99
+ created_at=time.time()
100
+ )
101
+ await grant_store.create_grant(grant)
102
+ ```
103
+
80
104
  ### MCP (Model Context Protocol)
81
105
  Wrap an MCP client to gate all tool calls:
82
106
  ```python
@@ -1,12 +1,13 @@
1
- tollgate/__init__.py,sha256=_JKKvkQRE_LpYbFTewyi_TC6BBKxaKqIJIQEIsVUHts,1322
1
+ tollgate/__init__.py,sha256=KWxtUI6D0Cm4QHCAaVv5VdEiQIo_krw7jzbXofxW_FM,1441
2
2
  tollgate/approvals.py,sha256=lalvun6i5yaZ2EGZ-apQJBrLVmht5OfwgiUFsImPdYE,7814
3
3
  tollgate/audit.py,sha256=ugMhuuLoyBNdYD2S_MjGN_ac4nHdtRz15MElqElREIs,1279
4
4
  tollgate/exceptions.py,sha256=2yYY3esnHz26dyJlx_Cd_J64ryhexrgc4KliDmiVJSs,882
5
+ tollgate/grants.py,sha256=H5soShiNcyQiY3gLJRqqP7ffAo2Udmnj3iU15sGi3os,5177
5
6
  tollgate/helpers.py,sha256=ZFMi19_ogpTlV_svFtgJ9kkmA1sPte1dmDnHYVdWGyo,1657
6
7
  tollgate/policy.py,sha256=1_6HcbdpGfZtwmgqapqeRzItc_wbEg_L-fOA-BVIWNg,6110
7
8
  tollgate/registry.py,sha256=ENKT-GnwFq0xXZj6qNlKFhAGYsxxBwtZsAdPZp2qlHM,2031
8
- tollgate/tower.py,sha256=JSCRTEyKpxst7bm_8trttvPeUXt-PZZLBVYgxLeOX_s,7801
9
- tollgate/types.py,sha256=Y__9nseQ7aM8TI1m2Voo5Ph-qJ5aDj7fLgJ-My2GsXc,3025
9
+ tollgate/tower.py,sha256=rPG2sdVe2GpdGiyHwWz8K4k7EJCL2NqDAGsMUkKlt30,8574
10
+ tollgate/types.py,sha256=Ks3HNawhPzEAggTCab7NLb8zl06777ehEusASJWSj_g,3811
10
11
  tollgate/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
12
  tollgate/integrations/mcp.py,sha256=jBy1d64Yc55fO4JSVfhKqiffVCZdBMmmNLRta9SVfDE,1750
12
13
  tollgate/integrations/strands.py,sha256=_AWsIyva_iwLFIQeTMxxbEzLSjneo0YumWfY9oayKeM,3003
@@ -14,7 +15,7 @@ tollgate/interceptors/__init__.py,sha256=0c3MYyVKGYrBOZ1mMolgrAowrqZrULzAl0vv-s8
14
15
  tollgate/interceptors/base.py,sha256=uJxHzH0eurcGEVknbk1kQkk_2u2qNtnM--ZRCp52Wyo,1390
15
16
  tollgate/interceptors/langchain.py,sha256=_8vXCjWkRKeTlxtXm33a67Gf4po9YiHS7faThMJLohc,2963
16
17
  tollgate/interceptors/openai.py,sha256=cHnOQ8keQJRYBm_1RpohKWk3D9Ask22hpuQghm9iahU,3337
17
- tollgate-1.0.3.dist-info/METADATA,sha256=bnpYItrHUP0lRCYmTYfRxog1J79JI1S8VAmpymzV-ZU,6963
18
- tollgate-1.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
- tollgate-1.0.3.dist-info/licenses/LICENSE,sha256=EZ9SehMCkcatlggcoT7WV0tx-ku4OsAoQf9LmJZvG1g,10806
20
- tollgate-1.0.3.dist-info/RECORD,,
18
+ tollgate-1.0.5.dist-info/METADATA,sha256=zPDa8CQ7Qqx5GoQ6XojAZgur47ErP6KeWoUCfhTQOXs,7748
19
+ tollgate-1.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
+ tollgate-1.0.5.dist-info/licenses/LICENSE,sha256=EZ9SehMCkcatlggcoT7WV0tx-ku4OsAoQf9LmJZvG1g,10806
21
+ tollgate-1.0.5.dist-info/RECORD,,