tollgate 1.0.2__tar.gz → 1.0.3__tar.gz

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.
Files changed (58) hide show
  1. {tollgate-1.0.2 → tollgate-1.0.3}/.claude/settings.local.json +4 -1
  2. {tollgate-1.0.2 → tollgate-1.0.3}/COMPARISON.md +3 -0
  3. {tollgate-1.0.2 → tollgate-1.0.3}/PKG-INFO +4 -1
  4. {tollgate-1.0.2 → tollgate-1.0.3}/QUICKSTART.md +3 -0
  5. {tollgate-1.0.2 → tollgate-1.0.3}/README.md +3 -0
  6. {tollgate-1.0.2 → tollgate-1.0.3}/pyproject.toml +1 -1
  7. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/__init__.py +1 -1
  8. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/approvals.py +39 -22
  9. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/interceptors/openai.py +19 -1
  10. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/policy.py +12 -2
  11. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/tower.py +35 -1
  12. {tollgate-1.0.2 → tollgate-1.0.3}/.gitignore +0 -0
  13. {tollgate-1.0.2 → tollgate-1.0.3}/CHANGELOG.md +0 -0
  14. {tollgate-1.0.2 → tollgate-1.0.3}/CONTRIBUTING.md +0 -0
  15. {tollgate-1.0.2 → tollgate-1.0.3}/LICENSE +0 -0
  16. {tollgate-1.0.2 → tollgate-1.0.3}/Makefile +0 -0
  17. {tollgate-1.0.2 → tollgate-1.0.3}/SECURITY.md +0 -0
  18. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mcp_minimal/audit.jsonl +0 -0
  19. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mcp_minimal/demo.py +0 -0
  20. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mcp_minimal/manifest.yaml +0 -0
  21. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mcp_minimal/policy.yaml +0 -0
  22. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/README.md +0 -0
  23. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/agent.py +0 -0
  24. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/demo.py +0 -0
  25. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/manifest.yaml +0 -0
  26. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/tickets.json +0 -0
  27. {tollgate-1.0.2 → tollgate-1.0.3}/examples/mock_tickets/tools.py +0 -0
  28. {tollgate-1.0.2 → tollgate-1.0.3}/examples/strands_minimal/audit.jsonl +0 -0
  29. {tollgate-1.0.2 → tollgate-1.0.3}/examples/strands_minimal/demo.py +0 -0
  30. {tollgate-1.0.2 → tollgate-1.0.3}/examples/strands_minimal/manifest.yaml +0 -0
  31. {tollgate-1.0.2 → tollgate-1.0.3}/examples/strands_minimal/policy.yaml +0 -0
  32. {tollgate-1.0.2 → tollgate-1.0.3}/policies/default.yaml +0 -0
  33. {tollgate-1.0.2 → tollgate-1.0.3}/specs/audit_event.schema.json +0 -0
  34. {tollgate-1.0.2 → tollgate-1.0.3}/specs/decision.schema.json +0 -0
  35. {tollgate-1.0.2 → tollgate-1.0.3}/specs/identity.schema.json +0 -0
  36. {tollgate-1.0.2 → tollgate-1.0.3}/specs/intent.schema.json +0 -0
  37. {tollgate-1.0.2 → tollgate-1.0.3}/specs/tool_request.schema.json +0 -0
  38. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/audit.py +0 -0
  39. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/exceptions.py +0 -0
  40. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/helpers.py +0 -0
  41. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/integrations/__init__.py +0 -0
  42. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/integrations/mcp.py +0 -0
  43. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/integrations/strands.py +0 -0
  44. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/interceptors/__init__.py +0 -0
  45. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/interceptors/base.py +0 -0
  46. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/interceptors/langchain.py +0 -0
  47. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/registry.py +0 -0
  48. {tollgate-1.0.2 → tollgate-1.0.3}/src/tollgate/types.py +0 -0
  49. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_adapters_v1.py +0 -0
  50. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_audit_integrity_v1.py +0 -0
  51. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_deferred_v1.py +0 -0
  52. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_helpers_v1.py +0 -0
  53. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_integrations_v1.py +0 -0
  54. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_policy_v1.py +0 -0
  55. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_registry_v1.py +0 -0
  56. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_security_v1.py +0 -0
  57. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_tower_v1.py +0 -0
  58. {tollgate-1.0.2 → tollgate-1.0.3}/tests/test_v1_integrations.py +0 -0
@@ -10,7 +10,10 @@
10
10
  "Bash(.venv/bin/ruff format:*)",
11
11
  "Bash(git init:*)",
12
12
  "Bash(gh auth status:*)",
13
- "Bash(.venv/bin/python:*)"
13
+ "Bash(.venv/bin/python:*)",
14
+ "Bash(python -m pytest tests/ -v)",
15
+ "Bash(python -m ruff:*)",
16
+ "Bash(python3 -m ruff check src/)"
14
17
  ]
15
18
  }
16
19
  }
@@ -2,6 +2,9 @@
2
2
 
3
3
  This guide demonstrates the minimal changes required to integrate `tollgate` into your existing AI agent workflows.
4
4
 
5
+ > [!CAUTION]
6
+ > **Disclaimer**: This project is an exploratory implementation intended for learning and discussion. It is not production-hardened and comes with no guarantees.
7
+
5
8
  ---
6
9
 
7
10
  ## 🔹 Scenario 1: Plain Python Tools
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tollgate
3
- Version: 1.0.2
3
+ Version: 1.0.3
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
@@ -29,6 +29,9 @@ Runtime enforcement layer for AI agent tool calls using **Identity + Intent + Po
29
29
 
30
30
  `tollgate` provides a deterministic safety boundary for AI agents. It ensures every tool call is validated against a policy before execution, with support for async human-in-the-loop approvals, framework interception (MCP, Strands, LangChain, OpenAI), and structured audit logging.
31
31
 
32
+ > [!CAUTION]
33
+ > **Disclaimer**: This project is an exploratory implementation intended for learning and discussion. It is not production-hardened and comes with no guarantees.
34
+
32
35
  **[🚀 Quickstart Guide](https://github.com/ravi-labs/tollgate/blob/main/QUICKSTART.md) | [📊 Integration Comparison](https://github.com/ravi-labs/tollgate/blob/main/COMPARISON.md)**
33
36
 
34
37
  ```
@@ -2,6 +2,9 @@
2
2
 
3
3
  Get started with `tollgate` in minutes. This guide covers the basic setup and integration options for various AI frameworks.
4
4
 
5
+ > [!CAUTION]
6
+ > **Disclaimer**: This project is an exploratory implementation intended for learning and discussion. It is not production-hardened and comes with no guarantees.
7
+
5
8
  ---
6
9
 
7
10
  ## 0. Key Concepts
@@ -4,6 +4,9 @@ Runtime enforcement layer for AI agent tool calls using **Identity + Intent + Po
4
4
 
5
5
  `tollgate` provides a deterministic safety boundary for AI agents. It ensures every tool call is validated against a policy before execution, with support for async human-in-the-loop approvals, framework interception (MCP, Strands, LangChain, OpenAI), and structured audit logging.
6
6
 
7
+ > [!CAUTION]
8
+ > **Disclaimer**: This project is an exploratory implementation intended for learning and discussion. It is not production-hardened and comes with no guarantees.
9
+
7
10
  **[🚀 Quickstart Guide](https://github.com/ravi-labs/tollgate/blob/main/QUICKSTART.md) | [📊 Integration Comparison](https://github.com/ravi-labs/tollgate/blob/main/COMPARISON.md)**
8
11
 
9
12
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tollgate"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Runtime enforcement layer for AI agent tool calls using Identity + Intent + Policy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -31,7 +31,7 @@ from .types import (
31
31
  ToolRequest,
32
32
  )
33
33
 
34
- __version__ = "1.0.2"
34
+ __version__ = "1.0.3"
35
35
 
36
36
  __all__ = [
37
37
  "ControlTower",
@@ -56,6 +56,7 @@ class InMemoryApprovalStore(ApprovalStore):
56
56
  def __init__(self):
57
57
  self._requests: dict[str, dict[str, Any]] = {}
58
58
  self._events: dict[str, asyncio.Event] = {}
59
+ self._lock = asyncio.Lock()
59
60
 
60
61
  async def create_request(
61
62
  self, agent_ctx, intent, tool_request, request_hash, reason, expiry
@@ -77,19 +78,21 @@ class InMemoryApprovalStore(ApprovalStore):
77
78
  async def set_decision(
78
79
  self, approval_id, outcome, decided_by, decided_at, request_hash
79
80
  ):
80
- if approval_id in self._requests:
81
- req = self._requests[approval_id]
82
- # Replay protection: hash must match
83
- if req["request_hash"] != request_hash:
84
- raise ValueError(
85
- "Request hash mismatch. Approval bound to a different request."
86
- )
87
-
88
- req["outcome"] = outcome
89
- req["decided_by"] = decided_by
90
- req["decided_at"] = decided_at
91
- if approval_id in self._events:
92
- self._events[approval_id].set()
81
+ # Security: Use lock for atomic read-modify-write operation
82
+ async with self._lock:
83
+ if approval_id in self._requests:
84
+ req = self._requests[approval_id]
85
+ # Replay protection: hash must match
86
+ if req["request_hash"] != request_hash:
87
+ raise ValueError(
88
+ "Request hash mismatch. Approval bound to a different request."
89
+ )
90
+
91
+ req["outcome"] = outcome
92
+ req["decided_by"] = decided_by
93
+ req["decided_at"] = decided_at
94
+ if approval_id in self._events:
95
+ self._events[approval_id].set()
93
96
 
94
97
  async def get_request(self, approval_id):
95
98
  return self._requests.get(approval_id)
@@ -172,21 +175,35 @@ class AutoApprover:
172
175
  class CliApprover:
173
176
  """Async-wrapped CLI approver for development."""
174
177
 
175
- def __init__(self, show_emojis: bool = True):
178
+ def __init__(self, show_emojis: bool = True, timeout: float = 300.0):
179
+ """
180
+ Initialize CliApprover.
181
+
182
+ :param show_emojis: Whether to display emojis in prompts.
183
+ :param timeout: Timeout in seconds for user input (default 5 minutes).
184
+ """
176
185
  self.show_emojis = show_emojis
186
+ self.timeout = timeout
177
187
 
178
188
  async def request_approval_async(
179
189
  self, agent_ctx, intent, tool_request, _hash, reason
180
190
  ) -> ApprovalOutcome:
181
191
  loop = asyncio.get_event_loop()
182
- return await loop.run_in_executor(
183
- None,
184
- self._sync_request,
185
- agent_ctx,
186
- intent,
187
- tool_request,
188
- reason,
189
- )
192
+ try:
193
+ return await asyncio.wait_for(
194
+ loop.run_in_executor(
195
+ None,
196
+ self._sync_request,
197
+ agent_ctx,
198
+ intent,
199
+ tool_request,
200
+ reason,
201
+ ),
202
+ timeout=self.timeout,
203
+ )
204
+ except asyncio.TimeoutError:
205
+ print("\nApproval request timed out.")
206
+ return ApprovalOutcome.TIMEOUT
190
207
 
191
208
  def _sync_request(self, agent_ctx, intent, tool_request, reason) -> ApprovalOutcome:
192
209
  prefix = "🚦 " if self.show_emojis else ""
@@ -3,6 +3,7 @@ import json
3
3
  from collections.abc import Callable
4
4
  from typing import Any
5
5
 
6
+ from ..exceptions import TollgateDenied
6
7
  from ..registry import ToolRegistry
7
8
  from ..tower import ControlTower
8
9
  from ..types import AgentContext, Intent, NormalizedToolCall, ToolRequest
@@ -26,10 +27,23 @@ class OpenAIAdapter:
26
27
  kwargs = {}
27
28
 
28
29
  tool_name = tc_dict.get("function", {}).get("name") or tc_dict.get("name")
30
+
31
+ # Security: Validate tool_name is not None or empty
32
+ if not tool_name:
33
+ raise TollgateDenied("Tool call missing required 'name' field")
34
+
29
35
  args_str = tc_dict.get("function", {}).get("arguments") or tc_dict.get(
30
36
  "arguments"
31
37
  )
32
- args = json.loads(args_str) if isinstance(args_str, str) else args_str
38
+
39
+ # Security: Safe JSON parsing with proper error handling
40
+ if isinstance(args_str, str):
41
+ try:
42
+ args = json.loads(args_str)
43
+ except json.JSONDecodeError as e:
44
+ raise TollgateDenied(f"Invalid JSON in tool arguments: {e.msg}") from e
45
+ else:
46
+ args = args_str if args_str is not None else {}
33
47
 
34
48
  registry_key = f"openai:{tool_name}"
35
49
  effect, resource_type, manifest_version = self.registry.resolve_tool(
@@ -48,6 +62,10 @@ class OpenAIAdapter:
48
62
  manifest_version=manifest_version,
49
63
  )
50
64
 
65
+ # Security: Handle missing tool in tool_map
66
+ if tool_name not in self.tool_map:
67
+ raise TollgateDenied(f"Unknown tool: {tool_name}")
68
+
51
69
  func = self.tool_map[tool_name]
52
70
 
53
71
  async def _exec_async():
@@ -20,6 +20,10 @@ class PolicyEvaluator(Protocol):
20
20
  class YamlPolicyEvaluator:
21
21
  """YAML-based policy evaluator with safe defaults."""
22
22
 
23
+ # Security: Whitelist of allowed attributes for agent_ctx and intent matching
24
+ ALLOWED_AGENT_ATTRS = frozenset({"agent_id", "version", "environment", "role"})
25
+ ALLOWED_INTENT_ATTRS = frozenset({"action", "reason", "session_id"})
26
+
23
27
  def __init__(
24
28
  self,
25
29
  policy_path: str | Path,
@@ -108,15 +112,21 @@ class YamlPolicyEvaluator:
108
112
  if "effect" in rule and rule["effect"] != req.effect.value:
109
113
  return False
110
114
 
111
- # Match Agent Context
115
+ # Match Agent Context (with attribute whitelist)
112
116
  if "agent" in rule:
113
117
  for key, expected_val in rule["agent"].items():
118
+ # Security: Only allow whitelisted attributes
119
+ if key not in self.ALLOWED_AGENT_ATTRS:
120
+ continue
114
121
  if getattr(agent_ctx, key, None) != expected_val:
115
122
  return False
116
123
 
117
- # Match Intent
124
+ # Match Intent (with attribute whitelist)
118
125
  if "intent" in rule:
119
126
  for key, expected_val in rule["intent"].items():
127
+ # Security: Only allow whitelisted attributes
128
+ if key not in self.ALLOWED_INTENT_ATTRS:
129
+ continue
120
130
  if getattr(intent, key, None) != expected_val:
121
131
  return False
122
132
 
@@ -127,7 +127,8 @@ class ControlTower:
127
127
  result = await exec_async()
128
128
  except Exception as e:
129
129
  outcome = Outcome.FAILED
130
- result_summary = f"{type(e).__name__}: {str(e)}"
130
+ # Security: Sanitize exception message to avoid info disclosure
131
+ result_summary = self._sanitize_exception(e)
131
132
  self._log(
132
133
  correlation_id,
133
134
  request_hash,
@@ -222,3 +223,36 @@ class ControlTower:
222
223
  return None
223
224
  s = str(result)
224
225
  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]"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes