tollgate 1.0.5__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/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
- def __init__(self, manifest_path: str | Path):
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,11 +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
  )
14
16
  from .grants import GrantStore
15
17
  from .policy import PolicyEvaluator
18
+ from .registry import ToolRegistry
16
19
  from .types import (
17
20
  AgentContext,
18
21
  ApprovalOutcome,
@@ -26,7 +29,20 @@ from .types import (
26
29
 
27
30
 
28
31
  class ControlTower:
29
- """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
+ """
30
46
 
31
47
  def __init__(
32
48
  self,
@@ -35,12 +51,22 @@ class ControlTower:
35
51
  audit: AuditSink,
36
52
  grant_store: GrantStore | None = None,
37
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
38
59
  ):
39
60
  self.policy = policy
40
61
  self.approver = approver
41
62
  self.audit = audit
42
63
  self.grant_store = grant_store
43
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
44
70
 
45
71
  @staticmethod
46
72
  def _default_redact(params: dict[str, Any]) -> dict[str, Any]:
@@ -71,10 +97,147 @@ class ControlTower:
71
97
  correlation_id = str(uuid.uuid4())
72
98
  request_hash = compute_request_hash(agent_ctx, intent, tool_request)
73
99
 
74
- # 1. Evaluate Policy
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
75
163
  decision = self.policy.evaluate(agent_ctx, intent, tool_request)
76
164
 
77
- # 2. Handle DENY
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
78
241
  if decision.decision == DecisionType.DENY:
79
242
  self._log(
80
243
  correlation_id,
@@ -87,15 +250,14 @@ class ControlTower:
87
250
  )
88
251
  raise TollgateDenied(decision.reason)
89
252
 
90
- # 3. Handle ASK
253
+ # 5. Handle ASK
91
254
  if decision.decision == DecisionType.ASK:
92
- # 3.1 Check Grants
255
+ # 5.1 Check Grants
93
256
  if self.grant_store:
94
257
  matching_grant = await self.grant_store.find_matching_grant(
95
258
  agent_ctx, tool_request
96
259
  )
97
260
  if matching_grant:
98
- # Grant found! Proceed to execution without asking approver
99
261
  result = await self._execute_and_log(
100
262
  correlation_id,
101
263
  request_hash,
@@ -108,13 +270,12 @@ class ControlTower:
108
270
  )
109
271
  return result
110
272
 
111
- # 3.2 Request Approval if no grant found
273
+ # 5.2 Request Approval if no grant found
112
274
  outcome = await self.approver.request_approval_async(
113
275
  agent_ctx, intent, tool_request, request_hash, decision.reason
114
276
  )
115
277
 
116
278
  if outcome == ApprovalOutcome.DEFERRED:
117
- # Audit the deferral
118
279
  self._log(
119
280
  correlation_id,
120
281
  request_hash,
@@ -122,7 +283,7 @@ class ControlTower:
122
283
  intent,
123
284
  tool_request,
124
285
  decision,
125
- Outcome.BLOCKED, # Deferral is a temporary block
286
+ Outcome.BLOCKED,
126
287
  )
127
288
  raise TollgateDeferred("pending")
128
289
 
@@ -143,7 +304,7 @@ class ControlTower:
143
304
  )
144
305
  raise TollgateApprovalDenied(f"Approval failed: {outcome.value}")
145
306
 
146
- # 4. Execute tool (Policy ALLOW or Approval APPROVED)
307
+ # 6. Execute tool (Policy ALLOW or Approval APPROVED)
147
308
  return await self._execute_and_log(
148
309
  correlation_id,
149
310
  request_hash,
@@ -172,6 +333,11 @@ class ControlTower:
172
333
  result = await exec_async()
173
334
  except Exception as e:
174
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
+ )
175
341
  result_summary = self._sanitize_exception(e)
176
342
  self._log(
177
343
  correlation_id,
@@ -186,6 +352,12 @@ class ControlTower:
186
352
  )
187
353
  raise
188
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
+
189
361
  # Final Audit
190
362
  result_summary = self._truncate_result(result)
191
363
  self._log(
@@ -270,7 +442,6 @@ class ControlTower:
270
442
 
271
443
  def _sanitize_exception(self, e: Exception) -> str:
272
444
  """Sanitize exception message to avoid leaking sensitive data."""
273
- # Only include the exception type and a generic message for security
274
445
  return f"{type(e).__name__}: Execution failed"
275
446
 
276
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
- return asdict(self)
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,
@@ -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