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/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,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: Any | None = None,
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
- # 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
74
163
  decision = self.policy.evaluate(agent_ctx, intent, tool_request)
75
164
 
76
- # 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
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
- # 3. Handle ASK
253
+ # 5. Handle ASK
90
254
  if decision.decision == DecisionType.ASK:
91
- # 3.1 Check Grants
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
- # 3.2 Request Approval if no grant found
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, # Deferral is a temporary block
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
- # 4. Execute tool (Policy ALLOW or Approval APPROVED)
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
- 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