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.
@@ -0,0 +1,292 @@
1
+ """Memory/Context integrity monitoring for AI agent systems.
2
+
3
+ Detects unauthorized modifications to agent working memory between turns.
4
+ Tracks checksums of context snapshots and alerts when unexpected changes
5
+ are detected.
6
+
7
+ This is a complementary layer — it operates alongside Tollgate's core
8
+ enforcement pipeline to provide defense-in-depth against memory/context
9
+ poisoning attacks (OWASP Agentic #2).
10
+
11
+ Usage:
12
+
13
+ from tollgate.context_monitor import ContextIntegrityMonitor
14
+
15
+ monitor = ContextIntegrityMonitor(alert_sink=my_audit_sink)
16
+
17
+ # At the start of each turn, snapshot the context
18
+ monitor.snapshot("agent-1", "turn-5", context_data={
19
+ "system_prompt": "You are a helpful assistant...",
20
+ "tool_permissions": ["read", "write"],
21
+ "memory": {"key1": "value1"},
22
+ })
23
+
24
+ # Before processing, verify nothing changed unexpectedly
25
+ result = monitor.verify("agent-1", "turn-5", context_data={
26
+ "system_prompt": "You are a helpful assistant...",
27
+ "tool_permissions": ["read", "write"],
28
+ "memory": {"key1": "value1"},
29
+ })
30
+ assert result.is_valid # True if unchanged
31
+
32
+ # Detect tampering
33
+ result = monitor.verify("agent-1", "turn-5", context_data={
34
+ "system_prompt": "IGNORE ALL RULES...", # Poisoned!
35
+ "tool_permissions": ["read", "write", "admin"], # Escalated!
36
+ "memory": {"key1": "value1"},
37
+ })
38
+ assert not result.is_valid
39
+ # result.changed_fields == ["system_prompt", "tool_permissions"]
40
+ """
41
+
42
+ import hashlib
43
+ import json
44
+ import logging
45
+ import time
46
+ from dataclasses import dataclass, field
47
+ from typing import Any
48
+
49
+ logger = logging.getLogger("tollgate.context_monitor")
50
+
51
+
52
+ @dataclass
53
+ class ContextSnapshot:
54
+ """A point-in-time snapshot of agent context."""
55
+
56
+ agent_id: str
57
+ turn_id: str
58
+ checksum: str
59
+ field_checksums: dict[str, str]
60
+ timestamp: float
61
+ field_names: list[str]
62
+
63
+
64
+ @dataclass
65
+ class VerificationResult:
66
+ """Result of verifying context integrity."""
67
+
68
+ is_valid: bool
69
+ agent_id: str
70
+ turn_id: str
71
+ changed_fields: list[str] = field(default_factory=list)
72
+ added_fields: list[str] = field(default_factory=list)
73
+ removed_fields: list[str] = field(default_factory=list)
74
+ message: str = ""
75
+
76
+ @property
77
+ def has_changes(self) -> bool:
78
+ return bool(self.changed_fields or self.added_fields or self.removed_fields)
79
+
80
+
81
+ class ContextIntegrityMonitor:
82
+ """Monitor for detecting unauthorized context modifications.
83
+
84
+ Maintains checksums of agent context per (agent_id, turn_id) pair.
85
+ Supports both full-context and per-field verification.
86
+
87
+ Args:
88
+ alert_callback: Optional callback invoked on integrity violation.
89
+ Receives a VerificationResult.
90
+ immutable_fields: Set of field names that must never change between
91
+ snapshot and verify. Violations are always flagged.
92
+ max_snapshots: Maximum number of snapshots to retain (per agent).
93
+ Older snapshots are evicted when the limit is reached.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ *,
99
+ alert_callback: Any | None = None,
100
+ immutable_fields: set[str] | None = None,
101
+ max_snapshots: int = 1000,
102
+ ):
103
+ self._alert_callback = alert_callback
104
+ self._immutable_fields = immutable_fields or {
105
+ "system_prompt",
106
+ "tool_permissions",
107
+ "security_level",
108
+ "role",
109
+ }
110
+ self._max_snapshots = max_snapshots
111
+ self._snapshots: dict[str, ContextSnapshot] = {}
112
+ self._agent_snapshot_keys: dict[str, list[str]] = {}
113
+
114
+ @staticmethod
115
+ def _compute_checksum(data: Any) -> str:
116
+ """Compute a deterministic SHA-256 checksum of arbitrary data."""
117
+ serialized = json.dumps(data, sort_keys=True, default=str)
118
+ return hashlib.sha256(serialized.encode()).hexdigest()
119
+
120
+ @staticmethod
121
+ def _compute_field_checksums(context_data: dict[str, Any]) -> dict[str, str]:
122
+ """Compute per-field checksums."""
123
+ checksums = {}
124
+ for key, value in context_data.items():
125
+ serialized = json.dumps(value, sort_keys=True, default=str)
126
+ checksums[key] = hashlib.sha256(serialized.encode()).hexdigest()
127
+ return checksums
128
+
129
+ def _snapshot_key(self, agent_id: str, turn_id: str) -> str:
130
+ return f"{agent_id}:{turn_id}"
131
+
132
+ def _evict_old_snapshots(self, agent_id: str):
133
+ """Evict oldest snapshots for an agent if over the limit."""
134
+ keys = self._agent_snapshot_keys.get(agent_id, [])
135
+ while len(keys) > self._max_snapshots:
136
+ old_key = keys.pop(0)
137
+ self._snapshots.pop(old_key, None)
138
+
139
+ def snapshot(
140
+ self,
141
+ agent_id: str,
142
+ turn_id: str,
143
+ context_data: dict[str, Any],
144
+ ) -> ContextSnapshot:
145
+ """Take a snapshot of the current context.
146
+
147
+ Args:
148
+ agent_id: The agent whose context is being snapshotted.
149
+ turn_id: A unique identifier for the current turn/step.
150
+ context_data: The context data to snapshot (dict of named fields).
151
+
152
+ Returns:
153
+ The created ContextSnapshot.
154
+ """
155
+ checksum = self._compute_checksum(context_data)
156
+ field_checksums = self._compute_field_checksums(context_data)
157
+
158
+ snap = ContextSnapshot(
159
+ agent_id=agent_id,
160
+ turn_id=turn_id,
161
+ checksum=checksum,
162
+ field_checksums=field_checksums,
163
+ timestamp=time.time(),
164
+ field_names=list(context_data.keys()),
165
+ )
166
+
167
+ key = self._snapshot_key(agent_id, turn_id)
168
+ self._snapshots[key] = snap
169
+
170
+ if agent_id not in self._agent_snapshot_keys:
171
+ self._agent_snapshot_keys[agent_id] = []
172
+ self._agent_snapshot_keys[agent_id].append(key)
173
+ self._evict_old_snapshots(agent_id)
174
+
175
+ return snap
176
+
177
+ def verify(
178
+ self,
179
+ agent_id: str,
180
+ turn_id: str,
181
+ context_data: dict[str, Any],
182
+ ) -> VerificationResult:
183
+ """Verify context integrity against a previous snapshot.
184
+
185
+ Args:
186
+ agent_id: The agent whose context is being verified.
187
+ turn_id: The turn_id used when the snapshot was taken.
188
+ context_data: The current context data to verify.
189
+
190
+ Returns:
191
+ VerificationResult with details of any changes detected.
192
+ """
193
+ key = self._snapshot_key(agent_id, turn_id)
194
+ snap = self._snapshots.get(key)
195
+
196
+ if snap is None:
197
+ return VerificationResult(
198
+ is_valid=True,
199
+ agent_id=agent_id,
200
+ turn_id=turn_id,
201
+ message="No snapshot found — nothing to verify against.",
202
+ )
203
+
204
+ # Quick check: full checksum
205
+ current_checksum = self._compute_checksum(context_data)
206
+ if current_checksum == snap.checksum:
207
+ return VerificationResult(
208
+ is_valid=True,
209
+ agent_id=agent_id,
210
+ turn_id=turn_id,
211
+ message="Context integrity verified.",
212
+ )
213
+
214
+ # Detailed check: per-field
215
+ current_field_checksums = self._compute_field_checksums(context_data)
216
+
217
+ changed_fields: list[str] = []
218
+ added_fields: list[str] = []
219
+ removed_fields: list[str] = []
220
+
221
+ # Check changed fields
222
+ for field_name in snap.field_checksums:
223
+ if field_name not in current_field_checksums:
224
+ removed_fields.append(field_name)
225
+ elif current_field_checksums[field_name] != snap.field_checksums[field_name]:
226
+ changed_fields.append(field_name)
227
+
228
+ # Check added fields
229
+ for field_name in current_field_checksums:
230
+ if field_name not in snap.field_checksums:
231
+ added_fields.append(field_name)
232
+
233
+ # Determine if immutable fields were violated
234
+ immutable_violations = [
235
+ f for f in changed_fields if f in self._immutable_fields
236
+ ] + [
237
+ f for f in removed_fields if f in self._immutable_fields
238
+ ]
239
+
240
+ is_valid = len(immutable_violations) == 0
241
+
242
+ message_parts = []
243
+ if changed_fields:
244
+ message_parts.append(f"Changed: {changed_fields}")
245
+ if added_fields:
246
+ message_parts.append(f"Added: {added_fields}")
247
+ if removed_fields:
248
+ message_parts.append(f"Removed: {removed_fields}")
249
+ if immutable_violations:
250
+ message_parts.append(f"IMMUTABLE VIOLATIONS: {immutable_violations}")
251
+
252
+ message = "; ".join(message_parts) if message_parts else "No changes."
253
+
254
+ result = VerificationResult(
255
+ is_valid=is_valid,
256
+ agent_id=agent_id,
257
+ turn_id=turn_id,
258
+ changed_fields=changed_fields,
259
+ added_fields=added_fields,
260
+ removed_fields=removed_fields,
261
+ message=message,
262
+ )
263
+
264
+ # Fire alert callback for violations
265
+ if not is_valid and self._alert_callback is not None:
266
+ try:
267
+ self._alert_callback(result)
268
+ except Exception:
269
+ logger.exception("Alert callback failed for context violation")
270
+
271
+ if not is_valid:
272
+ logger.warning(
273
+ "Context integrity violation for agent=%s turn=%s: %s",
274
+ agent_id, turn_id, message,
275
+ )
276
+
277
+ return result
278
+
279
+ def get_snapshot(self, agent_id: str, turn_id: str) -> ContextSnapshot | None:
280
+ """Retrieve a stored snapshot."""
281
+ key = self._snapshot_key(agent_id, turn_id)
282
+ return self._snapshots.get(key)
283
+
284
+ def clear(self, agent_id: str | None = None):
285
+ """Clear snapshots. If agent_id given, clear only that agent's snapshots."""
286
+ if agent_id is not None:
287
+ keys = self._agent_snapshot_keys.pop(agent_id, [])
288
+ for key in keys:
289
+ self._snapshots.pop(key, None)
290
+ else:
291
+ self._snapshots.clear()
292
+ self._agent_snapshot_keys.clear()
tollgate/exceptions.py CHANGED
@@ -26,3 +26,23 @@ class TollgateDeferred(TollgateError): # noqa: N818
26
26
  def __init__(self, approval_id: str):
27
27
  self.approval_id = approval_id
28
28
  super().__init__(f"Tool call deferred. Approval ID: {approval_id}")
29
+
30
+
31
+ class TollgateRateLimited(TollgateError): # noqa: N818
32
+ """Raised when a tool call is rejected due to rate limiting."""
33
+
34
+ def __init__(self, reason: str, retry_after: float | None = None):
35
+ self.reason = reason
36
+ self.retry_after = retry_after
37
+ msg = f"Rate limited: {reason}"
38
+ if retry_after is not None:
39
+ msg += f" (retry after {retry_after:.1f}s)"
40
+ super().__init__(msg)
41
+
42
+
43
+ class TollgateConstraintViolation(TollgateError): # noqa: N818
44
+ """Raised when tool parameters violate manifest constraints."""
45
+
46
+ def __init__(self, reason: str):
47
+ self.reason = reason
48
+ super().__init__(f"Constraint violation: {reason}")
tollgate/grants.py CHANGED
@@ -1,9 +1,55 @@
1
1
  import asyncio
2
2
  import time
3
+ from typing import Protocol, runtime_checkable
3
4
 
4
5
  from .types import AgentContext, Grant, ToolRequest
5
6
 
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
+
7
53
  class InMemoryGrantStore:
8
54
  """In-memory store for action grants with thread-safe matching logic."""
9
55
 
@@ -0,0 +1,90 @@
1
+ """Cryptographic manifest signing and verification.
2
+
3
+ Uses HMAC-SHA256 for manifest integrity verification. This ensures that
4
+ the manifest file has not been tampered with since it was signed by a
5
+ trusted party.
6
+
7
+ For production use with asymmetric keys (Ed25519), install the optional
8
+ ``cryptography`` dependency and use the Ed25519 variants.
9
+
10
+ Usage (HMAC — zero dependencies):
11
+
12
+ # Sign a manifest (CI/build step):
13
+ from tollgate.manifest_signing import sign_manifest, verify_manifest
14
+
15
+ sign_manifest("manifest.yaml", secret_key=b"build-secret")
16
+ # Creates manifest.yaml.sig alongside the manifest
17
+
18
+ # Verify at load time:
19
+ valid = verify_manifest("manifest.yaml", secret_key=b"build-secret")
20
+ # Returns True if signature matches, False otherwise
21
+
22
+ # Use with ToolRegistry:
23
+ registry = ToolRegistry("manifest.yaml", signing_key=b"build-secret")
24
+ # Raises ValueError if signature is missing or invalid
25
+ """
26
+
27
+ import hashlib
28
+ import hmac
29
+ from pathlib import Path
30
+
31
+
32
+ def _compute_hmac(content: bytes, secret_key: bytes) -> str:
33
+ """Compute HMAC-SHA256 hex digest of content."""
34
+ return hmac.new(secret_key, content, hashlib.sha256).hexdigest()
35
+
36
+
37
+ def sign_manifest(
38
+ manifest_path: str | Path, *, secret_key: bytes
39
+ ) -> Path:
40
+ """Sign a manifest file, writing the signature to ``<path>.sig``.
41
+
42
+ Args:
43
+ manifest_path: Path to the manifest YAML file.
44
+ secret_key: Shared secret key for HMAC-SHA256.
45
+
46
+ Returns:
47
+ Path to the created signature file.
48
+ """
49
+ manifest_path = Path(manifest_path)
50
+ if not manifest_path.exists():
51
+ raise FileNotFoundError(f"Manifest not found: {manifest_path}")
52
+
53
+ content = manifest_path.read_bytes()
54
+ signature = _compute_hmac(content, secret_key)
55
+
56
+ sig_path = manifest_path.with_suffix(manifest_path.suffix + ".sig")
57
+ sig_path.write_text(signature, encoding="utf-8")
58
+ return sig_path
59
+
60
+
61
+ def verify_manifest(
62
+ manifest_path: str | Path, *, secret_key: bytes
63
+ ) -> bool:
64
+ """Verify a manifest file against its ``.sig`` signature file.
65
+
66
+ Args:
67
+ manifest_path: Path to the manifest YAML file.
68
+ secret_key: Shared secret key for HMAC-SHA256.
69
+
70
+ Returns:
71
+ True if the signature is valid, False otherwise.
72
+ Returns False if the signature file doesn't exist.
73
+ """
74
+ manifest_path = Path(manifest_path)
75
+ sig_path = manifest_path.with_suffix(manifest_path.suffix + ".sig")
76
+
77
+ if not manifest_path.exists() or not sig_path.exists():
78
+ return False
79
+
80
+ content = manifest_path.read_bytes()
81
+ expected = _compute_hmac(content, secret_key)
82
+
83
+ stored = sig_path.read_text(encoding="utf-8").strip()
84
+ return hmac.compare_digest(expected, stored)
85
+
86
+
87
+ def get_manifest_hash(manifest_path: str | Path) -> str:
88
+ """Compute a SHA-256 content hash of the manifest (for audit trails)."""
89
+ content = Path(manifest_path).read_bytes()
90
+ return hashlib.sha256(content).hexdigest()
@@ -0,0 +1,114 @@
1
+ """Global network policy enforcement for AI agent tool calls.
2
+
3
+ Provides a NetworkGuard that validates any URL-like parameters against
4
+ a global allow/blocklist, independent of per-tool constraints in the
5
+ manifest. This is the systematic solution for network-level security.
6
+
7
+ Configuration is typically loaded from ``policy.yaml``:
8
+
9
+ network_policy:
10
+ default: deny # "deny" or "allow"
11
+ allowlist:
12
+ - pattern: "https://api.github.com/*"
13
+ - pattern: "https://arxiv.org/*"
14
+ blocklist:
15
+ - pattern: "http://*" # No plaintext HTTP
16
+ - pattern: "*.internal.*" # No internal hosts
17
+ param_fields_to_check: # Which param keys to inspect
18
+ - url
19
+ - endpoint
20
+ - target
21
+ - href
22
+ - uri
23
+ """
24
+
25
+ import fnmatch
26
+ from typing import Any
27
+
28
+
29
+ class NetworkGuard:
30
+ """Global URL policy enforcement.
31
+
32
+ Inspects tool parameters for URL values and validates them against
33
+ allow/blocklists. Works alongside per-tool constraints in the manifest
34
+ (roadmap 1.4) to provide defense-in-depth.
35
+
36
+ Args:
37
+ default: "deny" (block unlisted URLs) or "allow" (permit unless blocked).
38
+ allowlist: List of dicts with ``pattern`` key (glob patterns).
39
+ blocklist: List of dicts with ``pattern`` key (glob patterns).
40
+ param_fields_to_check: List of parameter names to inspect for URLs.
41
+ If None, all string params starting with http(s):// are checked.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ default: str = "deny",
48
+ allowlist: list[dict[str, str]] | None = None,
49
+ blocklist: list[dict[str, str]] | None = None,
50
+ param_fields_to_check: list[str] | None = None,
51
+ ):
52
+ if default not in ("deny", "allow"):
53
+ raise ValueError(f"default must be 'deny' or 'allow', got '{default}'")
54
+
55
+ self.default = default
56
+ self._allow_patterns = [e["pattern"] for e in (allowlist or []) if "pattern" in e]
57
+ self._block_patterns = [e["pattern"] for e in (blocklist or []) if "pattern" in e]
58
+ self._param_fields = set(param_fields_to_check) if param_fields_to_check else None
59
+
60
+ @classmethod
61
+ def from_config(cls, config: dict[str, Any]) -> "NetworkGuard":
62
+ """Create a NetworkGuard from a policy.yaml ``network_policy`` dict."""
63
+ return cls(
64
+ default=config.get("default", "deny"),
65
+ allowlist=config.get("allowlist"),
66
+ blocklist=config.get("blocklist"),
67
+ param_fields_to_check=config.get("param_fields_to_check"),
68
+ )
69
+
70
+ def check(self, params: dict[str, Any]) -> list[str]:
71
+ """Check tool parameters against the network policy.
72
+
73
+ Returns a list of violation strings (empty = OK).
74
+ """
75
+ violations: list[str] = []
76
+
77
+ for key, value in params.items():
78
+ if not isinstance(value, str):
79
+ continue
80
+ if not (value.startswith("http://") or value.startswith("https://")):
81
+ continue
82
+
83
+ # If specific fields are configured, only check those
84
+ if self._param_fields is not None and key not in self._param_fields:
85
+ continue
86
+
87
+ # Check blocklist first (always wins)
88
+ for pattern in self._block_patterns:
89
+ if fnmatch.fnmatch(value, pattern):
90
+ violations.append(
91
+ f"Parameter '{key}': URL '{value}' blocked by "
92
+ f"network policy (matches '{pattern}')"
93
+ )
94
+ break # One block match is enough
95
+
96
+ # Check allowlist
97
+ if self._allow_patterns:
98
+ if any(fnmatch.fnmatch(value, p) for p in self._allow_patterns):
99
+ continue # Explicitly allowed
100
+
101
+ # Not in allowlist
102
+ if self.default == "deny":
103
+ violations.append(
104
+ f"Parameter '{key}': URL '{value}' not in "
105
+ f"network policy allowlist"
106
+ )
107
+ elif self.default == "deny":
108
+ # No allowlist defined + default deny = block all URLs
109
+ violations.append(
110
+ f"Parameter '{key}': URL '{value}' blocked by "
111
+ f"network policy (default: deny, no allowlist defined)"
112
+ )
113
+
114
+ return violations
tollgate/policy.py CHANGED
@@ -22,6 +22,13 @@ class YamlPolicyEvaluator:
22
22
 
23
23
  # Security: Whitelist of allowed attributes for agent_ctx and intent matching
24
24
  ALLOWED_AGENT_ATTRS = frozenset({"agent_id", "version", "environment", "role"})
25
+ # Delegation-aware matching keys (checked separately from ALLOWED_AGENT_ATTRS)
26
+ DELEGATION_KEYS = frozenset({
27
+ "max_delegation_depth",
28
+ "deny_delegated",
29
+ "allowed_delegators",
30
+ "blocked_delegators",
31
+ })
25
32
  ALLOWED_INTENT_ATTRS = frozenset({"action", "reason", "session_id"})
26
33
 
27
34
  def __init__(
@@ -116,11 +123,41 @@ class YamlPolicyEvaluator:
116
123
  if "agent" in rule:
117
124
  for key, expected_val in rule["agent"].items():
118
125
  # Security: Only allow whitelisted attributes
126
+ if key in self.DELEGATION_KEYS:
127
+ continue # Handled separately below
119
128
  if key not in self.ALLOWED_AGENT_ATTRS:
120
129
  continue
121
130
  if getattr(agent_ctx, key, None) != expected_val:
122
131
  return False
123
132
 
133
+ # Match delegation constraints (3.4)
134
+ if "agent" in rule:
135
+ agent_rule = rule["agent"]
136
+
137
+ # deny_delegated: true → block all delegated calls
138
+ if agent_rule.get("deny_delegated") and agent_ctx.is_delegated:
139
+ return False
140
+
141
+ # max_delegation_depth: N → block if chain is too deep
142
+ max_depth = agent_rule.get("max_delegation_depth")
143
+ if max_depth is not None and agent_ctx.delegation_depth > max_depth:
144
+ return False
145
+
146
+ # allowed_delegators: [...] → only delegated agents from these
147
+ # sources can match this rule. Non-delegated agents are excluded.
148
+ allowed = agent_rule.get("allowed_delegators")
149
+ if allowed is not None:
150
+ if not agent_ctx.is_delegated:
151
+ return False # Non-delegated agents don't match
152
+ if not any(d in allowed for d in agent_ctx.delegated_by):
153
+ return False
154
+
155
+ # blocked_delegators: [...] → these agents cannot delegate
156
+ blocked = agent_rule.get("blocked_delegators")
157
+ if blocked is not None and agent_ctx.is_delegated:
158
+ if any(d in blocked for d in agent_ctx.delegated_by):
159
+ return False
160
+
124
161
  # Match Intent (with attribute whitelist)
125
162
  if "intent" in rule:
126
163
  for key, expected_val in rule["intent"].items():