tollgate 1.0.4__py3-none-any.whl → 1.4.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.
- tollgate/__init__.py +36 -3
- tollgate/anomaly_detector.py +396 -0
- tollgate/audit.py +90 -1
- tollgate/backends/__init__.py +37 -0
- tollgate/backends/redis_store.py +411 -0
- tollgate/backends/sqlite_store.py +458 -0
- tollgate/circuit_breaker.py +206 -0
- tollgate/context_monitor.py +292 -0
- tollgate/exceptions.py +20 -0
- tollgate/grants.py +46 -0
- tollgate/manifest_signing.py +90 -0
- tollgate/network_guard.py +114 -0
- tollgate/policy.py +37 -0
- tollgate/policy_testing.py +360 -0
- tollgate/rate_limiter.py +162 -0
- tollgate/registry.py +225 -2
- tollgate/tower.py +184 -12
- tollgate/types.py +21 -1
- tollgate/verification.py +81 -0
- tollgate-1.4.0.dist-info/METADATA +393 -0
- tollgate-1.4.0.dist-info/RECORD +33 -0
- tollgate-1.4.0.dist-info/entry_points.txt +2 -0
- tollgate-1.0.4.dist-info/METADATA +0 -144
- tollgate-1.0.4.dist-info/RECORD +0 -21
- {tollgate-1.0.4.dist-info → tollgate-1.4.0.dist-info}/WHEEL +0 -0
- {tollgate-1.0.4.dist-info → tollgate-1.4.0.dist-info}/licenses/LICENSE +0 -0
tollgate/registry.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import fnmatch
|
|
1
2
|
import hashlib
|
|
3
|
+
import re
|
|
2
4
|
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
import yaml
|
|
5
8
|
|
|
@@ -7,13 +10,36 @@ from .types import Effect
|
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
class ToolRegistry:
|
|
10
|
-
"""A registry of trusted tool metadata.
|
|
13
|
+
"""A registry of trusted tool metadata.
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
Supports:
|
|
16
|
+
- Tool effect / resource_type resolution (core v1.0)
|
|
17
|
+
- Parameter schema validation (v1.1 — roadmap 1.1)
|
|
18
|
+
- Tool constraints such as URL allowlisting (v1.1 — roadmap 1.4)
|
|
19
|
+
- Cryptographic manifest signing verification (v1.2 — roadmap 2.2)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
manifest_path: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
signing_key: bytes | None = None,
|
|
27
|
+
):
|
|
13
28
|
self.path = Path(manifest_path)
|
|
14
29
|
if not self.path.exists():
|
|
15
30
|
raise FileNotFoundError(f"Manifest file not found: {manifest_path}")
|
|
16
31
|
|
|
32
|
+
# 2.2: Verify manifest signature before loading
|
|
33
|
+
if signing_key is not None:
|
|
34
|
+
from .manifest_signing import verify_manifest
|
|
35
|
+
|
|
36
|
+
if not verify_manifest(self.path, secret_key=signing_key):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Manifest signature verification failed for {manifest_path}. "
|
|
39
|
+
"The manifest may have been tampered with, or the "
|
|
40
|
+
"signature file (.sig) is missing."
|
|
41
|
+
)
|
|
42
|
+
|
|
17
43
|
with self.path.open("r") as f:
|
|
18
44
|
content = f.read()
|
|
19
45
|
self.data = yaml.safe_load(content)
|
|
@@ -56,3 +82,200 @@ class ToolRegistry:
|
|
|
56
82
|
effect = Effect(meta.get("effect", "unknown"))
|
|
57
83
|
resource_type = meta.get("resource_type", "unknown")
|
|
58
84
|
return effect, resource_type, self.version
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# 1.1 Parameter Schema Validation
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def get_params_schema(self, tool_key: str) -> dict[str, Any] | None:
|
|
91
|
+
"""Return the params_schema for a tool, or None if not defined."""
|
|
92
|
+
meta = self.tools.get(tool_key)
|
|
93
|
+
if not meta:
|
|
94
|
+
return None
|
|
95
|
+
return meta.get("params_schema")
|
|
96
|
+
|
|
97
|
+
def validate_params(
|
|
98
|
+
self, tool_key: str, params: dict[str, Any]
|
|
99
|
+
) -> list[str]:
|
|
100
|
+
"""Validate tool parameters against the manifest schema.
|
|
101
|
+
|
|
102
|
+
Returns a list of validation error strings. An empty list means
|
|
103
|
+
the parameters are valid (or no schema is defined for this tool).
|
|
104
|
+
|
|
105
|
+
The validator is intentionally self-contained (no ``jsonschema``
|
|
106
|
+
dependency) and supports a practical subset of JSON Schema:
|
|
107
|
+
|
|
108
|
+
- ``type`` (string, number, integer, boolean, object, array, null)
|
|
109
|
+
- ``required`` (list of required property names)
|
|
110
|
+
- ``properties`` (per-property sub-schemas)
|
|
111
|
+
- ``pattern`` (regex for string values)
|
|
112
|
+
- ``maxLength`` / ``minLength``
|
|
113
|
+
- ``minimum`` / ``maximum``
|
|
114
|
+
- ``enum``
|
|
115
|
+
"""
|
|
116
|
+
schema = self.get_params_schema(tool_key)
|
|
117
|
+
if schema is None:
|
|
118
|
+
return [] # No schema ⇒ no validation errors
|
|
119
|
+
|
|
120
|
+
return self._validate_value(params, schema, path="params")
|
|
121
|
+
|
|
122
|
+
def _validate_value(
|
|
123
|
+
self, value: Any, schema: dict[str, Any], path: str
|
|
124
|
+
) -> list[str]:
|
|
125
|
+
"""Recursively validate a value against a JSON-Schema-like dict."""
|
|
126
|
+
errors: list[str] = []
|
|
127
|
+
|
|
128
|
+
# --- type ---
|
|
129
|
+
if "type" in schema:
|
|
130
|
+
expected = schema["type"]
|
|
131
|
+
if not self._type_matches(value, expected):
|
|
132
|
+
errors.append(f"{path}: expected type '{expected}', got {type(value).__name__}")
|
|
133
|
+
return errors # No point checking further if type is wrong
|
|
134
|
+
|
|
135
|
+
# --- enum ---
|
|
136
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
137
|
+
errors.append(f"{path}: value {value!r} not in enum {schema['enum']}")
|
|
138
|
+
|
|
139
|
+
# --- string constraints ---
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
if "minLength" in schema and len(value) < schema["minLength"]:
|
|
142
|
+
errors.append(f"{path}: length {len(value)} < minLength {schema['minLength']}")
|
|
143
|
+
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
144
|
+
errors.append(f"{path}: length {len(value)} > maxLength {schema['maxLength']}")
|
|
145
|
+
if "pattern" in schema and not re.search(schema["pattern"], value):
|
|
146
|
+
errors.append(f"{path}: value does not match pattern '{schema['pattern']}'")
|
|
147
|
+
|
|
148
|
+
# --- numeric constraints ---
|
|
149
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
150
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
151
|
+
errors.append(f"{path}: value {value} < minimum {schema['minimum']}")
|
|
152
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
153
|
+
errors.append(f"{path}: value {value} > maximum {schema['maximum']}")
|
|
154
|
+
|
|
155
|
+
# --- object: required + properties ---
|
|
156
|
+
if isinstance(value, dict):
|
|
157
|
+
if "required" in schema:
|
|
158
|
+
for req_key in schema["required"]:
|
|
159
|
+
if req_key not in value:
|
|
160
|
+
errors.append(f"{path}: missing required key '{req_key}'")
|
|
161
|
+
|
|
162
|
+
if "properties" in schema:
|
|
163
|
+
for prop_key, prop_schema in schema["properties"].items():
|
|
164
|
+
if prop_key in value:
|
|
165
|
+
errors.extend(
|
|
166
|
+
self._validate_value(
|
|
167
|
+
value[prop_key], prop_schema, path=f"{path}.{prop_key}"
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# --- array: items ---
|
|
172
|
+
if isinstance(value, list) and "items" in schema:
|
|
173
|
+
for i, item in enumerate(value):
|
|
174
|
+
errors.extend(
|
|
175
|
+
self._validate_value(item, schema["items"], path=f"{path}[{i}]")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return errors
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _type_matches(value: Any, expected: str) -> bool:
|
|
182
|
+
"""Check if a Python value matches a JSON Schema type string."""
|
|
183
|
+
type_map = {
|
|
184
|
+
"string": str,
|
|
185
|
+
"integer": int,
|
|
186
|
+
"number": (int, float),
|
|
187
|
+
"boolean": bool,
|
|
188
|
+
"object": dict,
|
|
189
|
+
"array": list,
|
|
190
|
+
"null": type(None),
|
|
191
|
+
}
|
|
192
|
+
py_type = type_map.get(expected)
|
|
193
|
+
if py_type is None:
|
|
194
|
+
return True # Unknown type ⇒ permissive
|
|
195
|
+
# bool is a subtype of int in Python — exclude it for "integer"/"number"
|
|
196
|
+
if expected in ("integer", "number") and isinstance(value, bool):
|
|
197
|
+
return False
|
|
198
|
+
return isinstance(value, py_type)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# 1.4 Tool Constraints (e.g. URL allowlisting)
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def get_constraints(self, tool_key: str) -> dict[str, Any] | None:
|
|
205
|
+
"""Return the constraints dict for a tool, or None."""
|
|
206
|
+
meta = self.tools.get(tool_key)
|
|
207
|
+
if not meta:
|
|
208
|
+
return None
|
|
209
|
+
return meta.get("constraints")
|
|
210
|
+
|
|
211
|
+
def check_constraints(
|
|
212
|
+
self, tool_key: str, params: dict[str, Any]
|
|
213
|
+
) -> list[str]:
|
|
214
|
+
"""Check tool parameters against manifest constraints.
|
|
215
|
+
|
|
216
|
+
Currently supports:
|
|
217
|
+
- ``allowed_url_patterns``: list of glob patterns. Any param
|
|
218
|
+
value that looks like a URL (starts with ``http://`` or
|
|
219
|
+
``https://``) is checked against these patterns.
|
|
220
|
+
- ``blocked_url_patterns``: list of glob patterns that are
|
|
221
|
+
explicitly denied.
|
|
222
|
+
- ``param_constraints``: per-parameter constraints with
|
|
223
|
+
``allowed_values`` or ``pattern``.
|
|
224
|
+
|
|
225
|
+
Returns a list of violation strings (empty = OK).
|
|
226
|
+
"""
|
|
227
|
+
constraints = self.get_constraints(tool_key)
|
|
228
|
+
if not constraints:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
violations: list[str] = []
|
|
232
|
+
|
|
233
|
+
# --- URL allowlisting ---
|
|
234
|
+
allowed_urls = constraints.get("allowed_url_patterns")
|
|
235
|
+
blocked_urls = constraints.get("blocked_url_patterns")
|
|
236
|
+
|
|
237
|
+
if allowed_urls or blocked_urls:
|
|
238
|
+
for param_key, param_val in params.items():
|
|
239
|
+
if isinstance(param_val, str) and (
|
|
240
|
+
param_val.startswith("http://") or param_val.startswith("https://")
|
|
241
|
+
):
|
|
242
|
+
# Check blocked first
|
|
243
|
+
if blocked_urls:
|
|
244
|
+
for pattern in blocked_urls:
|
|
245
|
+
if fnmatch.fnmatch(param_val, pattern):
|
|
246
|
+
violations.append(
|
|
247
|
+
f"Parameter '{param_key}': URL '{param_val}' "
|
|
248
|
+
f"matches blocked pattern '{pattern}'"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Check allowed (only if allowlist is defined)
|
|
252
|
+
if allowed_urls:
|
|
253
|
+
if not any(
|
|
254
|
+
fnmatch.fnmatch(param_val, p) for p in allowed_urls
|
|
255
|
+
):
|
|
256
|
+
violations.append(
|
|
257
|
+
f"Parameter '{param_key}': URL '{param_val}' "
|
|
258
|
+
f"does not match any allowed URL pattern"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# --- Per-parameter constraints ---
|
|
262
|
+
param_constraints = constraints.get("param_constraints")
|
|
263
|
+
if param_constraints:
|
|
264
|
+
for param_key, pc in param_constraints.items():
|
|
265
|
+
if param_key not in params:
|
|
266
|
+
continue
|
|
267
|
+
val = params[param_key]
|
|
268
|
+
|
|
269
|
+
if "allowed_values" in pc and val not in pc["allowed_values"]:
|
|
270
|
+
violations.append(
|
|
271
|
+
f"Parameter '{param_key}': value {val!r} "
|
|
272
|
+
f"not in allowed_values {pc['allowed_values']}"
|
|
273
|
+
)
|
|
274
|
+
if "pattern" in pc and isinstance(val, str):
|
|
275
|
+
if not re.search(pc["pattern"], val):
|
|
276
|
+
violations.append(
|
|
277
|
+
f"Parameter '{param_key}': value does not match "
|
|
278
|
+
f"pattern '{pc['pattern']}'"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return violations
|
tollgate/tower.py
CHANGED
|
@@ -8,10 +8,14 @@ from .approvals import Approver, compute_request_hash
|
|
|
8
8
|
from .audit import AuditSink
|
|
9
9
|
from .exceptions import (
|
|
10
10
|
TollgateApprovalDenied,
|
|
11
|
+
TollgateConstraintViolation,
|
|
11
12
|
TollgateDeferred,
|
|
12
13
|
TollgateDenied,
|
|
14
|
+
TollgateRateLimited,
|
|
13
15
|
)
|
|
16
|
+
from .grants import GrantStore
|
|
14
17
|
from .policy import PolicyEvaluator
|
|
18
|
+
from .registry import ToolRegistry
|
|
15
19
|
from .types import (
|
|
16
20
|
AgentContext,
|
|
17
21
|
ApprovalOutcome,
|
|
@@ -25,21 +29,44 @@ from .types import (
|
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
class ControlTower:
|
|
28
|
-
"""Async-first control tower for tool execution enforcement.
|
|
32
|
+
"""Async-first control tower for tool execution enforcement.
|
|
33
|
+
|
|
34
|
+
Enforcement pipeline (in order):
|
|
35
|
+
0. Verify agent identity (if verify_fn configured)
|
|
36
|
+
0.5. Check circuit breaker (if circuit_breaker configured) [2.1]
|
|
37
|
+
1. Check rate limits (if rate_limiter configured)
|
|
38
|
+
2. Evaluate policy
|
|
39
|
+
2.5. Check global network policy (if network_guard configured) [2.3]
|
|
40
|
+
3. Validate parameters against schema (if registry configured)
|
|
41
|
+
4. Check constraints (if registry configured)
|
|
42
|
+
5. Handle DENY / ASK / ALLOW
|
|
43
|
+
6. Execute tool → record success/failure in circuit breaker
|
|
44
|
+
7. Audit
|
|
45
|
+
"""
|
|
29
46
|
|
|
30
47
|
def __init__(
|
|
31
48
|
self,
|
|
32
49
|
policy: PolicyEvaluator,
|
|
33
50
|
approver: Approver,
|
|
34
51
|
audit: AuditSink,
|
|
35
|
-
grant_store:
|
|
52
|
+
grant_store: GrantStore | None = None,
|
|
36
53
|
redact_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
54
|
+
rate_limiter: Any | None = None, # RateLimiter protocol
|
|
55
|
+
registry: ToolRegistry | None = None,
|
|
56
|
+
verify_fn: Callable[[AgentContext], bool] | None = None,
|
|
57
|
+
circuit_breaker: Any | None = None, # CircuitBreaker protocol
|
|
58
|
+
network_guard: Any | None = None, # NetworkGuard
|
|
37
59
|
):
|
|
38
60
|
self.policy = policy
|
|
39
61
|
self.approver = approver
|
|
40
62
|
self.audit = audit
|
|
41
63
|
self.grant_store = grant_store
|
|
42
64
|
self.redact_fn = redact_fn or self._default_redact
|
|
65
|
+
self.rate_limiter = rate_limiter
|
|
66
|
+
self.registry = registry
|
|
67
|
+
self.verify_fn = verify_fn
|
|
68
|
+
self.circuit_breaker = circuit_breaker
|
|
69
|
+
self.network_guard = network_guard
|
|
43
70
|
|
|
44
71
|
@staticmethod
|
|
45
72
|
def _default_redact(params: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -70,10 +97,147 @@ class ControlTower:
|
|
|
70
97
|
correlation_id = str(uuid.uuid4())
|
|
71
98
|
request_hash = compute_request_hash(agent_ctx, intent, tool_request)
|
|
72
99
|
|
|
73
|
-
#
|
|
100
|
+
# 0. Verify agent identity (roadmap 1.6)
|
|
101
|
+
if self.verify_fn is not None:
|
|
102
|
+
if not self.verify_fn(agent_ctx):
|
|
103
|
+
decision = Decision(
|
|
104
|
+
decision=DecisionType.DENY,
|
|
105
|
+
reason="Agent identity verification failed.",
|
|
106
|
+
)
|
|
107
|
+
self._log(
|
|
108
|
+
correlation_id,
|
|
109
|
+
request_hash,
|
|
110
|
+
agent_ctx,
|
|
111
|
+
intent,
|
|
112
|
+
tool_request,
|
|
113
|
+
decision,
|
|
114
|
+
Outcome.BLOCKED,
|
|
115
|
+
)
|
|
116
|
+
raise TollgateDenied("Agent identity verification failed.")
|
|
117
|
+
|
|
118
|
+
# 0.5. Check circuit breaker (roadmap 2.1)
|
|
119
|
+
if self.circuit_breaker is not None:
|
|
120
|
+
cb_allowed, cb_reason = await self.circuit_breaker.before_call(
|
|
121
|
+
tool_request.tool, tool_request.action
|
|
122
|
+
)
|
|
123
|
+
if not cb_allowed:
|
|
124
|
+
decision = Decision(
|
|
125
|
+
decision=DecisionType.DENY,
|
|
126
|
+
reason=cb_reason or "Circuit breaker open.",
|
|
127
|
+
)
|
|
128
|
+
self._log(
|
|
129
|
+
correlation_id,
|
|
130
|
+
request_hash,
|
|
131
|
+
agent_ctx,
|
|
132
|
+
intent,
|
|
133
|
+
tool_request,
|
|
134
|
+
decision,
|
|
135
|
+
Outcome.BLOCKED,
|
|
136
|
+
)
|
|
137
|
+
raise TollgateDenied(cb_reason or "Circuit breaker open.")
|
|
138
|
+
|
|
139
|
+
# 1. Check rate limits (roadmap 1.2)
|
|
140
|
+
if self.rate_limiter is not None:
|
|
141
|
+
allowed, reason, retry_after = await self.rate_limiter.check_rate_limit(
|
|
142
|
+
agent_ctx, tool_request
|
|
143
|
+
)
|
|
144
|
+
if not allowed:
|
|
145
|
+
decision = Decision(
|
|
146
|
+
decision=DecisionType.DENY,
|
|
147
|
+
reason=reason or "Rate limit exceeded.",
|
|
148
|
+
)
|
|
149
|
+
self._log(
|
|
150
|
+
correlation_id,
|
|
151
|
+
request_hash,
|
|
152
|
+
agent_ctx,
|
|
153
|
+
intent,
|
|
154
|
+
tool_request,
|
|
155
|
+
decision,
|
|
156
|
+
Outcome.BLOCKED,
|
|
157
|
+
)
|
|
158
|
+
raise TollgateRateLimited(
|
|
159
|
+
reason or "Rate limit exceeded.", retry_after
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# 2. Evaluate Policy
|
|
74
163
|
decision = self.policy.evaluate(agent_ctx, intent, tool_request)
|
|
75
164
|
|
|
76
|
-
# 2.
|
|
165
|
+
# 2.5. Check global network policy (roadmap 2.3)
|
|
166
|
+
if (
|
|
167
|
+
decision.decision != DecisionType.DENY
|
|
168
|
+
and self.network_guard is not None
|
|
169
|
+
):
|
|
170
|
+
net_violations = self.network_guard.check(tool_request.params)
|
|
171
|
+
if net_violations:
|
|
172
|
+
deny_reason = (
|
|
173
|
+
f"Network policy violation: {'; '.join(net_violations)}"
|
|
174
|
+
)
|
|
175
|
+
decision = Decision(
|
|
176
|
+
decision=DecisionType.DENY,
|
|
177
|
+
reason=deny_reason,
|
|
178
|
+
)
|
|
179
|
+
self._log(
|
|
180
|
+
correlation_id,
|
|
181
|
+
request_hash,
|
|
182
|
+
agent_ctx,
|
|
183
|
+
intent,
|
|
184
|
+
tool_request,
|
|
185
|
+
decision,
|
|
186
|
+
Outcome.BLOCKED,
|
|
187
|
+
)
|
|
188
|
+
raise TollgateConstraintViolation(deny_reason)
|
|
189
|
+
|
|
190
|
+
# 3. Validate parameters against schema (roadmap 1.1)
|
|
191
|
+
if (
|
|
192
|
+
decision.decision != DecisionType.DENY
|
|
193
|
+
and self.registry is not None
|
|
194
|
+
):
|
|
195
|
+
schema_errors = self.registry.validate_params(
|
|
196
|
+
tool_request.tool, tool_request.params
|
|
197
|
+
)
|
|
198
|
+
if schema_errors:
|
|
199
|
+
deny_reason = (
|
|
200
|
+
f"Parameter validation failed: {'; '.join(schema_errors)}"
|
|
201
|
+
)
|
|
202
|
+
decision = Decision(
|
|
203
|
+
decision=DecisionType.DENY,
|
|
204
|
+
reason=deny_reason,
|
|
205
|
+
)
|
|
206
|
+
self._log(
|
|
207
|
+
correlation_id,
|
|
208
|
+
request_hash,
|
|
209
|
+
agent_ctx,
|
|
210
|
+
intent,
|
|
211
|
+
tool_request,
|
|
212
|
+
decision,
|
|
213
|
+
Outcome.BLOCKED,
|
|
214
|
+
)
|
|
215
|
+
raise TollgateDenied(deny_reason)
|
|
216
|
+
|
|
217
|
+
# 3.5. Check per-tool constraints (roadmap 1.4)
|
|
218
|
+
constraint_violations = self.registry.check_constraints(
|
|
219
|
+
tool_request.tool, tool_request.params
|
|
220
|
+
)
|
|
221
|
+
if constraint_violations:
|
|
222
|
+
deny_reason = (
|
|
223
|
+
f"Constraint violation: {'; '.join(constraint_violations)}"
|
|
224
|
+
)
|
|
225
|
+
decision = Decision(
|
|
226
|
+
decision=DecisionType.DENY,
|
|
227
|
+
reason=deny_reason,
|
|
228
|
+
)
|
|
229
|
+
self._log(
|
|
230
|
+
correlation_id,
|
|
231
|
+
request_hash,
|
|
232
|
+
agent_ctx,
|
|
233
|
+
intent,
|
|
234
|
+
tool_request,
|
|
235
|
+
decision,
|
|
236
|
+
Outcome.BLOCKED,
|
|
237
|
+
)
|
|
238
|
+
raise TollgateConstraintViolation(deny_reason)
|
|
239
|
+
|
|
240
|
+
# 4. Handle DENY
|
|
77
241
|
if decision.decision == DecisionType.DENY:
|
|
78
242
|
self._log(
|
|
79
243
|
correlation_id,
|
|
@@ -86,15 +250,14 @@ class ControlTower:
|
|
|
86
250
|
)
|
|
87
251
|
raise TollgateDenied(decision.reason)
|
|
88
252
|
|
|
89
|
-
#
|
|
253
|
+
# 5. Handle ASK
|
|
90
254
|
if decision.decision == DecisionType.ASK:
|
|
91
|
-
#
|
|
255
|
+
# 5.1 Check Grants
|
|
92
256
|
if self.grant_store:
|
|
93
257
|
matching_grant = await self.grant_store.find_matching_grant(
|
|
94
258
|
agent_ctx, tool_request
|
|
95
259
|
)
|
|
96
260
|
if matching_grant:
|
|
97
|
-
# Grant found! Proceed to execution without asking approver
|
|
98
261
|
result = await self._execute_and_log(
|
|
99
262
|
correlation_id,
|
|
100
263
|
request_hash,
|
|
@@ -107,13 +270,12 @@ class ControlTower:
|
|
|
107
270
|
)
|
|
108
271
|
return result
|
|
109
272
|
|
|
110
|
-
#
|
|
273
|
+
# 5.2 Request Approval if no grant found
|
|
111
274
|
outcome = await self.approver.request_approval_async(
|
|
112
275
|
agent_ctx, intent, tool_request, request_hash, decision.reason
|
|
113
276
|
)
|
|
114
277
|
|
|
115
278
|
if outcome == ApprovalOutcome.DEFERRED:
|
|
116
|
-
# Audit the deferral
|
|
117
279
|
self._log(
|
|
118
280
|
correlation_id,
|
|
119
281
|
request_hash,
|
|
@@ -121,7 +283,7 @@ class ControlTower:
|
|
|
121
283
|
intent,
|
|
122
284
|
tool_request,
|
|
123
285
|
decision,
|
|
124
|
-
Outcome.BLOCKED,
|
|
286
|
+
Outcome.BLOCKED,
|
|
125
287
|
)
|
|
126
288
|
raise TollgateDeferred("pending")
|
|
127
289
|
|
|
@@ -142,7 +304,7 @@ class ControlTower:
|
|
|
142
304
|
)
|
|
143
305
|
raise TollgateApprovalDenied(f"Approval failed: {outcome.value}")
|
|
144
306
|
|
|
145
|
-
#
|
|
307
|
+
# 6. Execute tool (Policy ALLOW or Approval APPROVED)
|
|
146
308
|
return await self._execute_and_log(
|
|
147
309
|
correlation_id,
|
|
148
310
|
request_hash,
|
|
@@ -171,6 +333,11 @@ class ControlTower:
|
|
|
171
333
|
result = await exec_async()
|
|
172
334
|
except Exception as e:
|
|
173
335
|
outcome = Outcome.FAILED
|
|
336
|
+
# Record failure in circuit breaker (roadmap 2.1)
|
|
337
|
+
if self.circuit_breaker is not None:
|
|
338
|
+
await self.circuit_breaker.record_failure(
|
|
339
|
+
tool_request.tool, tool_request.action
|
|
340
|
+
)
|
|
174
341
|
result_summary = self._sanitize_exception(e)
|
|
175
342
|
self._log(
|
|
176
343
|
correlation_id,
|
|
@@ -185,6 +352,12 @@ class ControlTower:
|
|
|
185
352
|
)
|
|
186
353
|
raise
|
|
187
354
|
|
|
355
|
+
# Record success in circuit breaker (roadmap 2.1)
|
|
356
|
+
if self.circuit_breaker is not None:
|
|
357
|
+
await self.circuit_breaker.record_success(
|
|
358
|
+
tool_request.tool, tool_request.action
|
|
359
|
+
)
|
|
360
|
+
|
|
188
361
|
# Final Audit
|
|
189
362
|
result_summary = self._truncate_result(result)
|
|
190
363
|
self._log(
|
|
@@ -269,7 +442,6 @@ class ControlTower:
|
|
|
269
442
|
|
|
270
443
|
def _sanitize_exception(self, e: Exception) -> str:
|
|
271
444
|
"""Sanitize exception message to avoid leaking sensitive data."""
|
|
272
|
-
# Only include the exception type and a generic message for security
|
|
273
445
|
return f"{type(e).__name__}: Execution failed"
|
|
274
446
|
|
|
275
447
|
def _truncate_result(self, result: Any, max_chars: int = 200) -> str | None:
|
tollgate/types.py
CHANGED
|
@@ -40,9 +40,27 @@ class AgentContext:
|
|
|
40
40
|
version: str
|
|
41
41
|
owner: str
|
|
42
42
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
delegated_by: tuple[str, ...] = field(default_factory=tuple)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def delegation_depth(self) -> int:
|
|
47
|
+
"""Number of agents in the delegation chain (0 = direct call)."""
|
|
48
|
+
return len(self.delegated_by)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_delegated(self) -> bool:
|
|
52
|
+
"""Whether this agent was delegated to by another agent."""
|
|
53
|
+
return len(self.delegated_by) > 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def root_agent(self) -> str:
|
|
57
|
+
"""The original (root) agent that started the delegation chain."""
|
|
58
|
+
return self.delegated_by[0] if self.delegated_by else self.agent_id
|
|
43
59
|
|
|
44
60
|
def to_dict(self) -> dict[str, Any]:
|
|
45
|
-
|
|
61
|
+
d = asdict(self)
|
|
62
|
+
d["delegated_by"] = list(self.delegated_by)
|
|
63
|
+
return d
|
|
46
64
|
|
|
47
65
|
|
|
48
66
|
@dataclass(frozen=True)
|
|
@@ -130,9 +148,11 @@ class AuditEvent:
|
|
|
130
148
|
result_summary: str | None = None
|
|
131
149
|
policy_version: str | None = None
|
|
132
150
|
manifest_version: str | None = None
|
|
151
|
+
schema_version: str = "1.0"
|
|
133
152
|
|
|
134
153
|
def to_dict(self) -> dict[str, Any]:
|
|
135
154
|
return {
|
|
155
|
+
"schema_version": self.schema_version,
|
|
136
156
|
"timestamp": self.timestamp,
|
|
137
157
|
"correlation_id": self.correlation_id,
|
|
138
158
|
"request_hash": self.request_hash,
|
tollgate/verification.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Agent identity verification via HMAC signing.
|
|
2
|
+
|
|
3
|
+
Provides utilities to sign and verify AgentContext instances so that
|
|
4
|
+
the ControlTower can trust agent_id claims are authentic.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from tollgate.verification import sign_agent_context, make_verifier
|
|
8
|
+
|
|
9
|
+
# At agent startup — sign the context
|
|
10
|
+
ctx = sign_agent_context(
|
|
11
|
+
AgentContext(agent_id="my-agent", version="1.0", owner="team-a"),
|
|
12
|
+
secret_key=b"shared-secret",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# At ControlTower — verify incoming contexts
|
|
16
|
+
tower = ControlTower(
|
|
17
|
+
policy=policy,
|
|
18
|
+
approver=approver,
|
|
19
|
+
audit=audit,
|
|
20
|
+
verify_fn=make_verifier(b"shared-secret"),
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import hmac
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .types import AgentContext
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compute_signature(agent_ctx: AgentContext, secret_key: bytes) -> str:
|
|
33
|
+
"""Compute HMAC-SHA256 over the canonical agent identity fields."""
|
|
34
|
+
payload = f"{agent_ctx.agent_id}|{agent_ctx.version}|{agent_ctx.owner}"
|
|
35
|
+
return hmac.new(secret_key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sign_agent_context(
|
|
39
|
+
agent_ctx: AgentContext, secret_key: bytes
|
|
40
|
+
) -> AgentContext:
|
|
41
|
+
"""Return a new AgentContext with an HMAC signature in metadata.
|
|
42
|
+
|
|
43
|
+
The signature covers ``agent_id``, ``version``, and ``owner``.
|
|
44
|
+
It is stored under ``metadata["_signature"]``.
|
|
45
|
+
"""
|
|
46
|
+
sig = _compute_signature(agent_ctx, secret_key)
|
|
47
|
+
new_meta: dict[str, Any] = {**agent_ctx.metadata, "_signature": sig}
|
|
48
|
+
return replace(agent_ctx, metadata=new_meta)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_agent_context(
|
|
52
|
+
agent_ctx: AgentContext, secret_key: bytes
|
|
53
|
+
) -> bool:
|
|
54
|
+
"""Verify that the AgentContext signature is valid.
|
|
55
|
+
|
|
56
|
+
Returns True if the signature matches, False otherwise.
|
|
57
|
+
Returns False if no signature is present.
|
|
58
|
+
"""
|
|
59
|
+
sig = agent_ctx.metadata.get("_signature")
|
|
60
|
+
if not sig or not isinstance(sig, str):
|
|
61
|
+
return False
|
|
62
|
+
expected = _compute_signature(agent_ctx, secret_key)
|
|
63
|
+
return hmac.compare_digest(sig, expected)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_verifier(
|
|
67
|
+
secret_key: bytes,
|
|
68
|
+
) -> "callable[[AgentContext], bool]":
|
|
69
|
+
"""Create a verification function suitable for ControlTower.verify_fn.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
tower = ControlTower(
|
|
73
|
+
...,
|
|
74
|
+
verify_fn=make_verifier(b"my-secret"),
|
|
75
|
+
)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def _verify(agent_ctx: AgentContext) -> bool:
|
|
79
|
+
return verify_agent_context(agent_ctx, secret_key)
|
|
80
|
+
|
|
81
|
+
return _verify
|