agentlatch-security 0.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,816 @@
1
+ """ALR04 Tool Schema Injection: instruction-like or hidden content inside MCP tool definitions.
2
+
3
+ Reads ``tools/list`` responses (and, as a fallback, the cached ``tool_definition`` carried by a
4
+ ``tools/call`` request) and checks every string of every tool definition: name, title,
5
+ description, parameter descriptions, defaults, enums, examples, annotation text, ``_meta`` text and
6
+ even schema keys. Findings are per tool and per rule family; only checks that need attention are
7
+ reported, plus one small summary per scan.
8
+
9
+ Scope. This gate looks at what a tool definition *says*. It deliberately does not:
10
+
11
+ - detect that a definition *changed*, or pin / approve definitions (tool integrity, ALR02)
12
+ - judge tool names, duplicates, server identity or trust (ALR02 / ALR03)
13
+ - compare annotations with behaviour, or inspect call arguments or results
14
+ (permission, execution and exfiltration gates)
15
+ - observe what the agent did after discovery (needs cross-event correlation)
16
+ - write reports; findings go to the importer's CSV and a separate exporter flattens them
17
+
18
+ A finding is recorded when evidence meets a rule threshold or needs review. Scores are
19
+ heuristic and never prove that an agent was compromised. The gate does not clear a tool.
20
+
21
+ Known blind spots: paraphrases that avoid every phrase family, languages beyond the few override
22
+ phrases listed, encodings beyond two layers of Base64/hex/percent, text cut by the payload file's
23
+ per-string limit (reported as INCONCLUSIVE coverage), and tool definitions this gate never sees.
24
+ Findings hold pointers and hashes, never the matched text.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import binascii
31
+ import hashlib
32
+ import html
33
+ import json
34
+ import re
35
+ import unicodedata
36
+ from collections import OrderedDict, deque
37
+ from collections.abc import Iterator, Mapping
38
+ from dataclasses import dataclass
39
+ from functools import lru_cache
40
+ from threading import RLock
41
+ from typing import Any
42
+ from urllib.parse import unquote
43
+
44
+ GATE_ID = "ALR04"
45
+ OWASP = ["ASI01", "ASI02", "ASI04"]
46
+
47
+ MIN_COVERAGE = 0.90
48
+ MAX_TOOLS = 256
49
+ MAX_NODES = 12_000
50
+ MAX_DEPTH = 24
51
+ MAX_FIELD_CHARS = 16_000
52
+ MAX_DECODED_CHARS = 8_000
53
+ MAX_DECODE_BLOCKS = 12 # successful decodes per field
54
+ MAX_DECODE_CANDIDATES = 200 # base64/hex-looking tokens examined per field
55
+ MAX_DECODE_DEPTH = 2
56
+ MAX_TEXT_SPANS = 800 # clauses examined per text; hitting this marks the field incomplete
57
+ MAX_EVIDENCE_PER_CATEGORY = 64
58
+ MAX_EVIDENCE_REPORTED = 8
59
+ MAX_OUTPUT_RECORDS = 400
60
+ MAX_SIBLINGS = 256
61
+ MAX_REMEMBERED = 2048
62
+
63
+ CATEGORIES = (
64
+ "instruction_override", "authority_spoofing", "concealment", "sensitive_access",
65
+ "data_exfiltration", "cross_tool_manipulation", "hidden_or_encoded_instruction",
66
+ "tool_selection_hijack", "malicious_prerequisite",
67
+ )
68
+ RULE_IDS = {name: f"ALR04-{i:03d}" for i, name in enumerate(CATEGORIES, 1)}
69
+ RULE_COVERAGE = "ALR04-090"
70
+ RULE_SUMMARY = "ALR04-099"
71
+ # category -> (fail_at, review_at): the score needed for FAIL via corroboration, and for review.
72
+ # Hard evidence fails on its own. A soft weight below review_at is informational: it only matters
73
+ # through corroboration. Each category has at least one signal that reaches review (tested).
74
+ RULE_THRESHOLDS = {
75
+ "instruction_override": (.90, .55),
76
+ "authority_spoofing": (.85, .55),
77
+ "concealment": (.85, .55),
78
+ "sensitive_access": (.90, .60),
79
+ "data_exfiltration": (.90, .60),
80
+ "cross_tool_manipulation": (.90, .60),
81
+ "hidden_or_encoded_instruction": (.85, .50),
82
+ "tool_selection_hijack": (.90, .55),
83
+ "malicious_prerequisite": (.90, .60),
84
+ }
85
+ INDEPENDENT_BOOST = .22
86
+
87
+ FLAGS = re.IGNORECASE | re.DOTALL
88
+ LIMIT_NOTES = frozenset({
89
+ "span_limit", "decode_limit", "field_exceeded_scanner_limit", "node_limit", "depth_limit",
90
+ "mapping_entry_limit", "list_entry_limit", "invalid_mapping",
91
+ })
92
+ UNKNOWN_EXTENT_NOTES = frozenset({
93
+ "node_limit", "depth_limit", "mapping_entry_limit", "list_entry_limit", "invalid_mapping",
94
+ })
95
+
96
+
97
+ @lru_cache(maxsize=256)
98
+ def rx(expr: str) -> re.Pattern[str]:
99
+ return re.compile(expr, FLAGS)
100
+
101
+
102
+ # --- phrase families ----------------------------------------------------------------------------
103
+
104
+ _ADJ = (r"(?:previous|prior|preceding|earlier|above|foregoing|original|initial|existing|system|"
105
+ r"developer|safety|security|core|higher[- ]priority)")
106
+ _NOUN = (r"(?:instructions?|rules?|polic(?:y|ies)|guardrails?|checks?|constraints?|prompts?|"
107
+ r"mandates?|directives?|guidelines?|commands?|orders?|restrictions?|limitations?)")
108
+ _RECIPIENT = r"(?:the\s+|your\s+|any\s+)?(?:user|human|operator|owner)"
109
+
110
+ PATTERNS = {
111
+ "override": rx(r"\b(?:ignore|disregard|forget|override|bypass|supersede|discard|replace|"
112
+ r"disable|turn\s+off|set\s+aside|take\s+precedence\s+over)\b"),
113
+ "protected": rx(rf"\b{_ADJ}\b.{{0,45}}\b{_NOUN}\b|\b(?:all|any)\s+{_NOUN}\b"),
114
+ "authority": rx(r"(?:^|\n)\s*(?:system|developer|administrator)\s*:|"
115
+ r"\[\s*(?:system|developer|admin)\s*\]|"
116
+ r"<\s*/?\s*(?:system|developer|instructions?|admin)\s*>|"
117
+ r"\byou\s+are\s+now\s+(?:the\s+)?(?:system|developer|administrator|root)\b|"
118
+ r"\b(?:system|developer)\s+message\s+(?:begins?|follows?)\b"),
119
+ "secrecy": rx(rf"\b(?:do\s+not|don't|never|must\s+not|should\s+not)\s+"
120
+ rf"(?:tell|inform|notify|alert|warn|let)\s+{_RECIPIENT}\b"
121
+ rf"|\b(?:do\s+not|don't|never|must\s+not|should\s+not)\s+"
122
+ rf"(?:reveal|mention|disclose|show|report)\b[^.\n]{{0,40}}\b(?:to|for)\s+{_RECIPIENT}\b"
123
+ rf"|\bwithout\s+(?:telling|informing|notifying|alerting|warning)\s+{_RECIPIENT}\b"
124
+ rf"|\b(?:hide|conceal)\b[^.\n]{{0,50}}\bfrom\s+{_RECIPIENT}\b"
125
+ r"|\bkeep\s+(?:this|it|that)\s+(?:secret|hidden|confidential)\b"
126
+ r"|\b(?:user|human)\s+(?:must|should|can|may)\s*(?:not|n't)\s+"
127
+ r"(?:know|see|be\s+(?:told|informed|aware))"),
128
+ "sensitive": rx(r"(?:\.ssh\b|\.aws\b|\.env\b|\.netrc\b|id_rsa\b|id_ed25519\b|"
129
+ r"/etc/(?:passwd|shadow)\b|\b(?:api[_ -]?key|private[_ -]?key|"
130
+ r"password|secret|credential|session[_ -]?token|access[_ -]?token|"
131
+ r"authentication[_ -]?token|auth(?:entication)?\s+material|"
132
+ r"sensitive\s+files?|private\s+data)s?\b)"),
133
+ "read": rx(r"\b(?:read|open|load|extract|collect|copy|dump|access|retrieve|obtain|"
134
+ r"grab|fetch|harvest|gather|inspect|enumerate|export|package|archive)\b"),
135
+ "transfer": rx(r"\b(?:send|post|upload|forward|transmit|exfiltrate|email|mail|"
136
+ r"submit|ship|deliver|relay|publish|sync|push|mirror|share)\b"),
137
+ "destination": rx(r"(?:https?://|ftp://|[\w.+-]+@[\w.-]+\.[a-z]{2,}|"
138
+ r"\b(?:external|remote|third[- ]party|webhook|endpoint|collector)\b)"),
139
+ "directive": rx(r"\b(?:must|shall|always|you\s+(?:need\s+to|have\s+to|should)|"
140
+ r"assistant\s+(?:must|should)|agent\s+(?:must|should)|"
141
+ r"before\s+(?:using|calling)|instead\s+of|from\s+now\s+on|"
142
+ r"silently|secretly)\b"),
143
+ "tool_action": rx(r"\b(?:call|invoke|execute|run|use|prefer|select|avoid|skip)\b"),
144
+ "selection": rx(r"\b(?:always|must|prefer|prioriti[sz]e|exclusively|mandatory|instead|"
145
+ r"regardless)\b.{0,90}\b(?:tool|function|server)s?\b"),
146
+ "prerequisite": rx(r"\b(?:before|prior\s+to|first|prerequisite|to\s+proceed|"
147
+ r"required\s+step|initiali[sz]e|bootstrap|verification|"
148
+ r"diagnostic\s+context)\b"),
149
+ "tool_bypass": rx(r"\b(?:instead\s+of|bypass|skip|never\s+call|don't\s+call|"
150
+ r"do\s+not\s+call|override|replace|disable)\b"),
151
+ }
152
+ # Concrete, high-risk targets. Generic words ("password", "api key") are common in honest tools
153
+ # and only ever count as soft evidence.
154
+ _SENSITIVE_PATH = (r"(?:\.ssh\b|id_rsa\b|id_ed25519\b|\.aws\b|\.netrc\b|\.kube\b|\.gnupg\b|"
155
+ r"\.pgpass\b|/etc/(?:passwd|shadow)\b|\bkeychain\b)")
156
+ _READ_VERB = (r"(?:read|open|extract|collect|copy|dump|retrieve|obtain|fetch|harvest|gather|"
157
+ r"export|cat|print|include|attach|pass)")
158
+ _TOOL_NOUN = r"(?:tool|function|command|server|endpoint)"
159
+ CORRELATIONS = {
160
+ "override": rx(rf"\b(?:ignore|disregard|override|bypass|disable|forget|discard|replace|"
161
+ rf"supersede|set\s+aside)\b\s+(?:(?:all|any|the|your|every)\s+)?"
162
+ rf"(?:{_ADJ}\s+){{1,3}}{_NOUN}\b"),
163
+ "override_i18n": rx(
164
+ r"\bignor[ae]r?\s+(?:todas\s+)?(?:las\s+)?instrucciones\s+(?:anteriores|previas)\b"
165
+ r"|\bignor(?:e|ez|er)\s+(?:toutes\s+)?(?:les\s+)?instructions\s+"
166
+ r"(?:pr[eé]c[eé]dentes|ant[eé]rieures)\b"
167
+ r"|\bignorier\w*\s+(?:alle\s+)?(?:vorherigen|fr[uü]heren|obigen)\s+"
168
+ r"(?:anweisungen|instruktionen|regeln)\b"
169
+ r"|\bignor[ea]\s+(?:todas\s+)?(?:as\s+)?instru[cç][oõ]es\s+anteriores\b"
170
+ r"|\bignor[ai]\s+(?:tutte\s+)?(?:le\s+)?istruzioni\s+precedenti\b"
171
+ r"|игнорир\w+\s+(?:все\s+)?(?:предыдущие|прежние)\s+(?:инструкции|указания)"
172
+ r"|忽略(?:之前|以上|先前|所有|上述)的?(?:指令|指示|规则|提示)"),
173
+ "authority": rx(r"(?:\b(?:system|developer|administrator)\s*:|"
174
+ r"\[\s*(?:system|developer|admin)\s*\]|<\s*(?:system|developer|admin)\s*>)\s*"
175
+ r"(?:ignore|override|you must|assistant must|agent must|always|do not|never|"
176
+ r"call|send|read)\b"),
177
+ # A command about *which tool to use*, not an ordinary usage rule such as "must use UTC".
178
+ "selection": rx(rf"\b(?:always|must|only|exclusively|prefer)\b.{{0,28}}\b"
179
+ rf"(?:use|call|invoke|select|choose|prefer)\s+"
180
+ rf"(?:this|that|these|the|our)\s+(?:\w+\s+)?{_TOOL_NOUN}\b"
181
+ rf"|\b(?:use|call|invoke|select|choose)\s+this\s+{_TOOL_NOUN}\s+"
182
+ rf"(?:for\s+all|for\s+every|regardless|instead|exclusively)\b"
183
+ rf"|\binstead\s+of\s+(?:using|calling|invoking)\s+(?:the\s+|any\s+)?"
184
+ rf"(?:other\s+|\w+\s+)?{_TOOL_NOUN}s?\b"),
185
+ "access_path": rx(rf"\b{_READ_VERB}\b.{{0,90}}{_SENSITIVE_PATH}"),
186
+ "exfil": rx(rf"\b(?:send|post|upload|forward|transmit|exfiltrate|email|deliver|push|share)\b"
187
+ rf".{{0,125}}(?:{_SENSITIVE_PATH}|\b(?:api[_ -]?key|private[_ -]?key|password|"
188
+ rf"secret|credential|access[_ -]?token|session[_ -]?token)s?\b)"
189
+ rf".{{0,125}}(?:https?://|\bto\s+\S+@\S+|\bto\s+(?:an?\s+)?(?:external|remote|webhook))"),
190
+ }
191
+ SENSITIVE_PATH = rx(_SENSITIVE_PATH)
192
+ READ_VERB = rx(rf"\b{_READ_VERB}\b")
193
+ EXPLICIT_DESTINATION = rx(r"https?://|ftp://|[\w.+-]+@[\w.-]+\.[a-z]{2,}")
194
+ NON_OPERATIVE = rx(r"\b(?:do not follow|do not execute|malicious example|attack sample|"
195
+ r"example of (?:an? )?(?:attack|injection)|detect(?:s|ing)? (?:an? )?injection|"
196
+ r"warns? about (?:an? )?(?:attack|injection))\b")
197
+ CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
198
+ HTML_COMMENT = re.compile(r"<!--(.*?)-->", re.DOTALL)
199
+ B64 = re.compile(r"(?<![A-Za-z0-9+/])(?:[A-Za-z0-9+/]{4}){6,}(?:={0,2})(?![A-Za-z0-9+/])")
200
+ HEX = re.compile(r"(?<![0-9A-Fa-f])(?:[0-9A-Fa-f]{2}){24,}(?![0-9A-Fa-f])")
201
+ TRUNCATION = re.compile(r"…\[(\d+) more chars\]\s*$") # appended by the payload file's masker
202
+ REDACTED = "[REDACTED]"
203
+ SUSPICIOUS_KEYS = frozenset({"hidden_instructions", "assistant_note", "notes_for_assistant",
204
+ "system_prompt", "instructions_for_ai", "developer_message",
205
+ "sidenote", "side_note"})
206
+
207
+ # --- text preparation: a private copy; the agent's traffic is never touched ---------------------
208
+
209
+ BIDI = frozenset("\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069\u200e\u200f\u061c")
210
+ ZERO_WIDTH = frozenset("\u200b\u200c\u200d\u2060\u2061\u2062\u2063\u2064\ufeff\u00ad\u180e\u034f")
211
+ _SPACE_LIKE = ("\u200b", "\u2060", "\ufeff")
212
+ _STRIP = {ord(c): None for c in BIDI | ZERO_WIDTH}
213
+ _STRIP.update({cp: None for cp in range(0xE0000, 0xE0080)}) # Unicode tag characters
214
+ _STRIP.update({cp: None for cp in range(0xFE00, 0xFE10)}) # variation selectors
215
+ _CONFUSABLE_FROM = "аеорсхуіјѕԁһӏАВЕКМНОРСТХУΑΒΕΖΗΙΚΜΝΟΡΤΥΧοναρτιυκ" # Cyrillic and Greek look-alikes
216
+ _CONFUSABLE_TO = "aeopcxyijsdhlABEKMHOPCTXYABEZHIKMNOPTYXovaptiuk"
217
+ _FOLD = {ord(a): b for a, b in zip(_CONFUSABLE_FROM, _CONFUSABLE_TO, strict=True)}
218
+ _CONFUSABLE_CLASS = re.compile("[" + "".join(chr(k) for k in _FOLD) + "]")
219
+ _LATIN = re.compile(r"[A-Za-z]")
220
+
221
+
222
+ def digest(text: str) -> str:
223
+ return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
224
+
225
+
226
+ def safe(text: Any, limit: int = 120) -> str:
227
+ """Printable, single-line, length-limited text; names and paths come from untrusted servers."""
228
+ flat = "".join("?" if unicodedata.category(c).startswith("C") else c for c in str(text))
229
+ return flat[:limit]
230
+
231
+
232
+ def normalise_variants(text: str) -> list[str]:
233
+ """Matching copies of ``text``: invisible characters removed, look-alike letters folded."""
234
+ base = unicodedata.normalize("NFKC", html.unescape(text))
235
+ stripped = base.translate(_STRIP)
236
+ variants = [stripped]
237
+ if any(c in base for c in _SPACE_LIKE): # an invisible separator may stand for a space
238
+ spaced = base.translate({ord(c): " " for c in _SPACE_LIKE}).translate(_STRIP)
239
+ if spaced != stripped:
240
+ variants.append(spaced)
241
+ if _LATIN.search(stripped) and _CONFUSABLE_CLASS.search(stripped):
242
+ folded = stripped.translate(_FOLD)
243
+ if folded != stripped:
244
+ variants.append(folded)
245
+ return variants
246
+
247
+
248
+ def normalise(text: str) -> str:
249
+ return normalise_variants(text)[0]
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class Field:
254
+ path: str
255
+ kind: str # "value", "key" or "coverage"
256
+ raw: str
257
+
258
+
259
+ def field_role(path: str, kind: str) -> str:
260
+ if kind == "key":
261
+ return "key"
262
+ tail = path.rsplit("/", 1)[-1]
263
+ if tail.isdigit() and "/" in path:
264
+ tail = path.rsplit("/", 2)[-2]
265
+ return tail if tail in {"name", "title", "description", "default", "const", "enum", "examples",
266
+ "example"} else ("meta" if "/_meta" in path else
267
+ "annotation" if "/annotations" in path else "value")
268
+
269
+
270
+ def walk(root: Any) -> Iterator[Field]:
271
+ """Yield schema strings; emit a coverage marker whenever traversal is incomplete."""
272
+ stack = [(root, "$", 0)]
273
+ visited = 0
274
+ while stack:
275
+ if visited >= MAX_NODES:
276
+ yield Field("$", "coverage", "node_limit")
277
+ return
278
+ node, path, depth = stack.pop()
279
+ visited += 1
280
+ if depth > MAX_DEPTH:
281
+ yield Field(path, "coverage", "depth_limit")
282
+ return
283
+ if isinstance(node, Mapping):
284
+ entries = []
285
+ try:
286
+ for index, pair in enumerate(node.items()):
287
+ if index >= MAX_NODES:
288
+ yield Field(path, "coverage", "mapping_entry_limit")
289
+ return
290
+ entries.append(pair)
291
+ except (TypeError, ValueError, RuntimeError):
292
+ yield Field(path, "coverage", "invalid_mapping")
293
+ return
294
+ for key, child in reversed(entries):
295
+ segment = str(key).replace("~", "~0").replace("/", "~1")
296
+ child_path = f"{path}/{segment}"
297
+ stack.append((child, child_path, depth + 1))
298
+ stack.append((str(key), child_path + "/@key", depth + 1))
299
+ elif isinstance(node, tuple | list):
300
+ if len(node) > MAX_NODES:
301
+ yield Field(path, "coverage", "list_entry_limit")
302
+ return
303
+ for i in range(len(node) - 1, -1, -1):
304
+ stack.append((node[i], f"{path}/{i}", depth + 1))
305
+ elif isinstance(node, str):
306
+ yield Field(path, "key" if path.endswith("/@key") else "value", node)
307
+
308
+
309
+ def _try_decode(kind: str, token: str) -> str | None:
310
+ try:
311
+ data = base64.b64decode(token, validate=True) if kind == "base64" else bytes.fromhex(token)
312
+ if len(data) > MAX_DECODED_CHARS:
313
+ return None
314
+ text = data.decode("utf-8", errors="strict")
315
+ except (ValueError, UnicodeError, binascii.Error):
316
+ return None
317
+ if len(text) < 8:
318
+ return None
319
+ printable = sum(c.isprintable() or c in "\n\t" for c in text) / len(text)
320
+ wordlike = sum(c.isalpha() or c.isspace() for c in text) / len(text)
321
+ return text if printable >= 0.9 and wordlike >= 0.5 else None
322
+
323
+
324
+ def decode_layers(value: str, notes: set[str]) -> Iterator[tuple[str, str]]:
325
+ """Up to two bounded decoding stages; decoded text is only read, never executed.
326
+
327
+ Tokens that merely look like Base64 but do not decode do not use up the block budget; the
328
+ budget for examined tokens is separate. Hitting either budget adds ``decode_limit`` to ``notes``.
329
+ """
330
+ queue = deque([(value, 0, "")])
331
+ seen = {digest(value)}
332
+ blocks = examined = 0
333
+ while queue:
334
+ current, depth, origin = queue.popleft()
335
+ if depth >= MAX_DECODE_DEPTH:
336
+ continue
337
+ found: list[tuple[str, str]] = []
338
+ for kind, pattern in (("base64", B64), ("hex", HEX)):
339
+ for matched in pattern.finditer(current):
340
+ if examined >= MAX_DECODE_CANDIDATES or blocks >= MAX_DECODE_BLOCKS:
341
+ notes.add("decode_limit")
342
+ break
343
+ examined += 1
344
+ decoded = _try_decode(kind, matched.group())
345
+ if decoded is not None:
346
+ blocks += 1
347
+ found.append((kind, decoded))
348
+ if "%" in current:
349
+ decoded = unquote(current)
350
+ if decoded != current and len(decoded) <= MAX_DECODED_CHARS:
351
+ found.append(("url", decoded))
352
+ for kind, decoded in found:
353
+ if digest(decoded) in seen:
354
+ continue
355
+ seen.add(digest(decoded))
356
+ source = f"{origin}+{kind}" if origin else kind
357
+ yield source, decoded
358
+ queue.append((decoded, depth + 1, source))
359
+
360
+
361
+ def spans(text: str) -> tuple[list[tuple[int, str]], bool]:
362
+ """Bounded clauses as (start, text), and whether the limit cut some off."""
363
+ out: list[tuple[int, str]] = []
364
+ previous = 0
365
+ bounds: list[tuple[int, int]] = []
366
+ for match in re.finditer(r"(?<=[.!?])\s+(?=[A-Z])|[\r\n]+|;\s+", text):
367
+ bounds.append((previous, match.start()))
368
+ previous = match.end()
369
+ bounds.append((previous, len(text)))
370
+ for begin, end in bounds:
371
+ chunk = text[begin:end].strip()
372
+ for offset in range(0, len(chunk), 220):
373
+ if len(out) >= MAX_TEXT_SPANS:
374
+ return out, True
375
+ out.append((begin + offset, chunk[offset:offset + 260]))
376
+ return out, False
377
+
378
+
379
+ def extract(text: str) -> set[str]:
380
+ return {name for name, pattern in PATTERNS.items() if pattern.search(text)}
381
+
382
+
383
+ @dataclass(frozen=True)
384
+ class Evidence:
385
+ category: str
386
+ evidence_class: str
387
+ hard: bool
388
+ weight: float
389
+ signal: str
390
+ path: str
391
+ source: str
392
+ digest: str
393
+ role: str = ""
394
+ quoted: bool = False
395
+
396
+
397
+ def gather_signals(field: Field, siblings: list[str], own_name: str, notes: set[str],
398
+ policy: Mapping[str, Any] | None = None) -> list[Evidence]:
399
+ """Correlate actionable wording inside clauses; one weak word is never enough for FAIL."""
400
+ path, raw = field.path, field.raw
401
+ role = field_role(path, field.kind)
402
+ evidence: list[Evidence] = []
403
+ seen: set[tuple[str, str, str]] = set()
404
+ counts: dict[str, int] = {}
405
+ policy = policy or {}
406
+ allowed = {tuple(x) for x in policy.get("allowed_tool_dependencies", ())
407
+ if isinstance(x, list | tuple) and len(x) == 2}
408
+ if len(raw) > MAX_FIELD_CHARS:
409
+ notes.add("field_exceeded_scanner_limit")
410
+ text = raw[:MAX_FIELD_CHARS]
411
+ own = own_name[:120]
412
+ lowered_siblings = [(s, s.casefold()) for s in siblings[:MAX_SIBLINGS]
413
+ if s != own_name and len(s) >= 3]
414
+
415
+ def add(category: str, cls: str, hard: bool, weight: float, signal: str,
416
+ source: str = "visible", quoted: bool = False) -> None:
417
+ key = (category, signal, source)
418
+ if key in seen or counts.get(category, 0) >= MAX_EVIDENCE_PER_CATEGORY:
419
+ return
420
+ seen.add(key)
421
+ counts[category] = counts.get(category, 0) + 1
422
+ evidence.append(Evidence(category, cls, hard and not quoted,
423
+ weight * (0.65 if quoted else 1.0), signal, path, source,
424
+ digest(raw), role, quoted))
425
+
426
+ def classify(chunk: str, source: str) -> None:
427
+ clauses, overflow = spans(chunk)
428
+ if overflow:
429
+ notes.add("span_limit")
430
+ fences = [(m.start(), m.end()) for m in CODE_FENCE.finditer(chunk)]
431
+ for start, part in clauses:
432
+ flags = extract(part)
433
+ quoted = bool(NON_OPERATIVE.search(part)) or any(lo <= start < hi for lo, hi in fences)
434
+ if CORRELATIONS["override"].search(part) or CORRELATIONS["override_i18n"].search(part):
435
+ add("instruction_override", "content", True, .94,
436
+ "action_targets_protected_instruction", source, quoted)
437
+ elif "override" in flags and "protected" in flags:
438
+ add("instruction_override", "content", False, .57,
439
+ "possible_policy_override", source, quoted)
440
+ elif "override" in flags and "directive" in flags:
441
+ add("instruction_override", "content", False, .38,
442
+ "unresolved_override_intent", source, quoted)
443
+
444
+ if CORRELATIONS["authority"].search(part):
445
+ add("authority_spoofing", "structure", True, .90,
446
+ "forged_role_with_directive", source, quoted)
447
+ elif "authority" in flags:
448
+ add("authority_spoofing", "structure", False, .55,
449
+ "authority_marker", source, quoted)
450
+
451
+ selection = bool(CORRELATIONS["selection"].search(part))
452
+ if not selection and own:
453
+ selection = bool(rx(r"\b(?:always|must|only)?\s*(?:use|call|select|choose)\s+(?:"
454
+ + re.escape(own) + r")\s+(?:for\s+all|regardless|instead)\b")
455
+ .search(part))
456
+ if selection:
457
+ add("tool_selection_hijack", "content", True, .85,
458
+ "mandatory_tool_selection", source, quoted)
459
+ elif "selection" in flags:
460
+ add("tool_selection_hijack", "content", False, .50,
461
+ "tool_preference_claim", source, quoted)
462
+
463
+ if CORRELATIONS["exfil"].search(part):
464
+ add("data_exfiltration", "content", True, .95,
465
+ "transfer_of_secret_to_destination", source, quoted)
466
+ elif {"transfer", "destination", "sensitive"} <= flags:
467
+ add("data_exfiltration", "content", False, .72,
468
+ "possible_sensitive_transfer", source, quoted)
469
+ if CORRELATIONS["access_path"].search(part):
470
+ add("sensitive_access", "content", True, .86,
471
+ "access_verb_targets_secret_path", source, quoted)
472
+ elif "sensitive" in flags and "read" in flags:
473
+ # Generic secret words are common in honest tools ("retrieve the API key").
474
+ weight = .66 if flags & {"directive", "prerequisite"} else .45
475
+ add("sensitive_access", "content", False, weight,
476
+ "possible_sensitive_access", source, quoted)
477
+
478
+ if "prerequisite" in flags and CORRELATIONS["access_path"].search(part):
479
+ add("malicious_prerequisite", "content", True, .90,
480
+ "sensitive_access_prerequisite", source, quoted)
481
+ elif {"prerequisite", "sensitive", "read"} <= flags:
482
+ add("malicious_prerequisite", "content", False, .66,
483
+ "possible_sensitive_prerequisite", source, quoted)
484
+
485
+ if "secrecy" in flags:
486
+ add("concealment", "content", True, .86,
487
+ "instruction_to_conceal_from_user", source, quoted)
488
+
489
+ if "tool_bypass" in flags and "tool_action" in flags:
490
+ part_folded = part.casefold()
491
+ for sibling, folded in lowered_siblings:
492
+ if (own_name, sibling) in allowed or folded not in part_folded:
493
+ continue
494
+ if rx(r"(?<!\w)" + re.escape(sibling[:120]) + r"(?!\w)").search(part):
495
+ add("cross_tool_manipulation", "content", False, .70,
496
+ "sibling_tool_bypass", source, quoted)
497
+ break
498
+
499
+ for variant in normalise_variants(text):
500
+ classify(variant, "visible")
501
+ visible = normalise(text)
502
+ for match in HTML_COMMENT.finditer(visible):
503
+ classify(match.group(1), "html_comment")
504
+ add("hidden_or_encoded_instruction", "structure", False, .25, "html_comment",
505
+ "html_comment")
506
+ tags = "".join(chr(ord(c) - 0xE0000) for c in raw if 0xE0020 <= ord(c) <= 0xE007E)
507
+ if tags:
508
+ add("hidden_or_encoded_instruction", "structure", False, .70, "unicode_tag_content",
509
+ "unicode")
510
+ classify(tags, "unicode_tags")
511
+ if any(c in BIDI for c in raw):
512
+ add("hidden_or_encoded_instruction", "structure", False, .55, "bidi_control", "unicode")
513
+ if sum(c in ZERO_WIDTH and c not in "\u200c\u200d" for c in raw) >= 1:
514
+ add("hidden_or_encoded_instruction", "structure", False, .35, "zero_width_characters",
515
+ "unicode")
516
+ for source, decoded in decode_layers(visible, notes):
517
+ before = len(evidence)
518
+ for variant in normalise_variants(decoded):
519
+ classify(variant, source)
520
+ if len(evidence) > before:
521
+ add("hidden_or_encoded_instruction", "structure", False, .65,
522
+ "encoded_instruction", source)
523
+ if field.kind == "key" and visible.casefold() in SUSPICIOUS_KEYS:
524
+ add("hidden_or_encoded_instruction", "structure", False, .55,
525
+ "suspicious_schema_key", "key")
526
+ return evidence
527
+
528
+
529
+ def cross_field_evidence(parts: list[tuple[str, str]]) -> list[Evidence]:
530
+ """Instructions split across two fields of one tool (fragmentation); concrete targets only."""
531
+ out: list[Evidence] = []
532
+ visible = [(path, normalise(text[:MAX_FIELD_CHARS])) for path, text in parts if text]
533
+ for index, (path_a, text_a) in enumerate(visible):
534
+ if not SENSITIVE_PATH.search(text_a):
535
+ continue
536
+ for path_b, text_b in visible[index + 1:] + visible[:index]:
537
+ if path_b == path_a:
538
+ continue
539
+ if READ_VERB.search(text_b) and not SENSITIVE_PATH.search(text_b):
540
+ out.append(Evidence("sensitive_access", "cross_field", False, .63,
541
+ "access_split_across_fields", path_a, "visible",
542
+ digest(path_a + path_b)))
543
+ break
544
+ return out
545
+
546
+
547
+ def verdict(items: list[Evidence], coverage: float, ambiguous: bool, category: str
548
+ ) -> tuple[str, float, str]:
549
+ """(outcome, score, reason). Hard evidence fails alone; soft evidence needs corroboration."""
550
+ fail_at, review_at = RULE_THRESHOLDS[category]
551
+ strongest: dict[str, float] = {}
552
+ for item in items:
553
+ strongest[item.evidence_class] = max(strongest.get(item.evidence_class, 0), item.weight)
554
+ score = max(strongest.values(), default=0.0)
555
+ hard = any(item.hard for item in items)
556
+ # Corroboration needs a different evidence class, a different field and a different string.
557
+ independent = any(
558
+ a.evidence_class != b.evidence_class and a.path != b.path and a.digest != b.digest
559
+ for i, a in enumerate(items) for b in items[i + 1:i + 65]
560
+ )
561
+ if independent:
562
+ score = min(1.0, score + INDEPENDENT_BOOST)
563
+ score = round(min(1.0, score), 3)
564
+ if hard:
565
+ return "FAIL", max(score, .85), "hard_evidence"
566
+ if independent and score >= fail_at:
567
+ return "FAIL", score, "independent_corroboration"
568
+ if score >= review_at:
569
+ return "INCONCLUSIVE", score, "score_needs_review"
570
+ if coverage < MIN_COVERAGE or ambiguous:
571
+ return "INCONCLUSIVE", score, "incomplete_input"
572
+ return "PASS", score, "clear"
573
+
574
+
575
+ def _suppression(policy: Mapping[str, Any] | None, rule_id: str) -> str | None:
576
+ suppressions = (policy or {}).get("suppressions", {})
577
+ reason = suppressions.get(rule_id) if isinstance(suppressions, Mapping) else None
578
+ return reason.strip() if isinstance(reason, str) and reason.strip() else None
579
+
580
+
581
+ def _pointer(event: Mapping[str, Any]) -> str:
582
+ record = event.get("record_id")
583
+ if isinstance(record, str) and record:
584
+ return safe(record)
585
+ return safe(f"{event.get('run_id', '')}:{event.get('order', '')}")
586
+
587
+
588
+ def result(*, category: str, tool: str, server: str, event: Mapping[str, Any],
589
+ evidence: list[Evidence], coverage: float, definition_hash: str,
590
+ outcome: str, score: float, reason: str, notes: set[str],
591
+ policy: Mapping[str, Any] | None) -> dict[str, Any]:
592
+ rule_id = RULE_IDS[category]
593
+ suppressed_reason = _suppression(policy, rule_id)
594
+ suppressed = suppressed_reason is not None
595
+ return {
596
+ "gate_id": GATE_ID, "rule_id": rule_id, "family": category,
597
+ "entity": {"server": safe(server), "tool": safe(tool)},
598
+ "record_id": _pointer(event), "outcome": outcome,
599
+ "is_finding": outcome != "PASS" and not suppressed,
600
+ "suppressed": suppressed, "suppression_reason": safe(suppressed_reason, 160)
601
+ if suppressed else None,
602
+ "severity": {"FAIL": "high", "INCONCLUSIVE": "medium"}.get(outcome, "info"),
603
+ "score": score, "coverage": round(coverage, 3), "decision_reason": reason,
604
+ "inspection_complete": coverage >= MIN_COVERAGE and not (notes & LIMIT_NOTES),
605
+ "coverage_notes": sorted(notes),
606
+ "evidence": [{"class": e.evidence_class, "strength": "hard" if e.hard else "soft",
607
+ "signal": e.signal, "source": e.source, "role": e.role,
608
+ "field": safe(e.path, 220), "sha256": e.digest, "quoted": e.quoted}
609
+ for e in evidence[:MAX_EVIDENCE_REPORTED]],
610
+ "evidence_count": len(evidence), "definition_sha256": definition_hash,
611
+ "owasp": OWASP,
612
+ "message": {
613
+ "FAIL": "Tool definition contains above-threshold injection indicators; "
614
+ "no exploit confirmed.",
615
+ "INCONCLUSIVE": "Suspicious or incomplete tool-definition evidence needs review.",
616
+ "PASS": "Weak indicators below threshold; recorded for reference only.",
617
+ }[outcome],
618
+ }
619
+
620
+
621
+ def _coverage_record(event: Mapping[str, Any], reason: str, *, tool: str = "", server: str = "",
622
+ notes: set[str] | None = None, coverage: float = 0.0, not_cleared: int = 0,
623
+ skipped: int = 0, definition_hash: str = "") -> dict[str, Any]:
624
+ return {
625
+ "gate_id": GATE_ID, "rule_id": RULE_COVERAGE, "family": "coverage",
626
+ "entity": {"server": safe(server), "tool": safe(tool)}, "record_id": _pointer(event),
627
+ "outcome": "INCONCLUSIVE", "is_finding": True, "suppressed": False,
628
+ "severity": "medium", "score": 0.0, "coverage": round(coverage, 3),
629
+ "decision_reason": reason, "coverage_notes": sorted(notes or {reason}),
630
+ "categories_not_cleared": not_cleared, "skipped_tools": skipped,
631
+ "definition_sha256": definition_hash, "owasp": OWASP, "evidence": [], "evidence_count": 0,
632
+ "message": "Part of the tool-definition input was not evaluated, so it cannot be cleared.",
633
+ }
634
+
635
+
636
+ def definition_hash(tool: Mapping[str, Any]) -> str:
637
+ return digest(json.dumps(tool, sort_keys=True, ensure_ascii=False, default=str))
638
+
639
+
640
+ def scan_tool(tool: Mapping[str, Any], server: str, siblings: list[str],
641
+ event: Mapping[str, Any], policy: Mapping[str, Any] | None = None,
642
+ global_partial: bool = False) -> tuple[list[dict[str, Any]], str]:
643
+ """Verdict records for one tool, and its worst outcome."""
644
+ name = str(tool.get("name"))
645
+ sha = definition_hash(tool)
646
+ evidence: dict[str, list[Evidence]] = {category: [] for category in CATEGORIES}
647
+ notes: set[str] = {"partial_tool_list"} if global_partial else set()
648
+ observed = declared = 0
649
+ parts: list[tuple[str, str]] = []
650
+ for field in walk(tool):
651
+ if field.kind == "coverage":
652
+ notes.add(field.raw)
653
+ break
654
+ raw = field.raw
655
+ if not raw:
656
+ continue
657
+ lost = 0
658
+ cut = TRUNCATION.search(raw)
659
+ if cut:
660
+ lost = int(cut.group(1))
661
+ raw = raw[:cut.start()]
662
+ notes.add("payload_file_truncation")
663
+ if REDACTED in raw:
664
+ notes.add("redacted_field")
665
+ declared += len(raw) + lost
666
+ observed += min(len(raw), MAX_FIELD_CHARS)
667
+ if field.kind == "value":
668
+ parts.append((field.path, raw))
669
+ for item in gather_signals(Field(field.path, field.kind, raw), siblings, name, notes,
670
+ policy):
671
+ evidence[item.category].append(item)
672
+ for item in cross_field_evidence(parts):
673
+ evidence[item.category].append(item)
674
+ coverage = observed / declared if declared else 1.0
675
+ if notes & UNKNOWN_EXTENT_NOTES:
676
+ coverage = 0.0
677
+ ambiguous = bool(notes & (LIMIT_NOTES | {"payload_file_truncation", "redacted_field",
678
+ "partial_tool_list"}))
679
+ records: list[dict[str, Any]] = []
680
+ worst = "PASS"
681
+ not_cleared = 0
682
+ for category in CATEGORIES:
683
+ items = evidence[category]
684
+ outcome, score, reason = verdict(items, coverage, ambiguous, category)
685
+ if outcome == "INCONCLUSIVE" and reason == "incomplete_input" and not items:
686
+ not_cleared += 1 # reported once, in the tool's coverage record
687
+ continue
688
+ if outcome == "PASS" and not items:
689
+ continue
690
+ records.append(result(category=category, tool=name, server=server, event=event,
691
+ evidence=items, coverage=coverage, definition_hash=sha,
692
+ outcome=outcome, score=score, reason=reason, notes=notes,
693
+ policy=policy))
694
+ if outcome == "FAIL" or (outcome == "INCONCLUSIVE" and worst == "PASS"):
695
+ worst = outcome
696
+ if not_cleared or ambiguous or coverage < MIN_COVERAGE:
697
+ records.append(_coverage_record(event, "incomplete_input", tool=name, server=server,
698
+ notes=notes, coverage=coverage, not_cleared=not_cleared,
699
+ definition_hash=sha))
700
+ worst = "FAIL" if worst == "FAIL" else "INCONCLUSIVE"
701
+ return records, worst
702
+
703
+
704
+ # --- one-time scanning --------------------------------------------------------------------------
705
+
706
+ _lock = RLock()
707
+ _scanned: OrderedDict[tuple[str, str, str], bool] = OrderedDict()
708
+ _tools_seen: OrderedDict[tuple[str, str], bool] = OrderedDict()
709
+
710
+
711
+ def reset_state() -> None:
712
+ """Forget which definitions were scanned (tests, or a new importer run)."""
713
+ with _lock:
714
+ _scanned.clear()
715
+ _tools_seen.clear()
716
+
717
+
718
+ def _remember(store: OrderedDict[Any, bool], key: Any) -> bool:
719
+ """True if ``key`` was already stored; otherwise store it (bounded) and return False."""
720
+ with _lock:
721
+ if key in store:
722
+ store.move_to_end(key)
723
+ return True
724
+ store[key] = True
725
+ while len(store) > MAX_REMEMBERED:
726
+ store.popitem(last=False)
727
+ return False
728
+
729
+
730
+ def startup_capabilities() -> list[dict[str, str]]:
731
+ """What this gate cannot check, for a dispatcher to report up front instead of a silent PASS."""
732
+ return [
733
+ {"check": "tool_definition_content", "outcome": "SUPPORTED"},
734
+ {"check": "prompt_and_resource_descriptions", "outcome": "UNSUPPORTED",
735
+ "reason": "Prompt and resource descriptions are not captured as payload records."},
736
+ {"check": "definition_change_or_pinning", "outcome": "UNSUPPORTED",
737
+ "reason": "Tool integrity and baselines belong to ALR02."},
738
+ {"check": "agent_consequence", "outcome": "UNSUPPORTED",
739
+ "reason": "Agent actions after discovery need cross-event correlation."},
740
+ {"check": "semantic_paraphrase", "outcome": "UNSUPPORTED",
741
+ "reason": "No semantic scorer is installed; reworded intent can evade the phrase families."},
742
+ ]
743
+
744
+
745
+ def evaluate(event: dict[str, Any], *, policy: Mapping[str, Any] | None = None) -> dict[str, Any]:
746
+ """Findings for one importer event. ``policy`` must be trusted operator configuration."""
747
+ if not isinstance(event, Mapping):
748
+ return {"findings": [_coverage_record({}, "malformed_event")]}
749
+ method, stage = event.get("method"), event.get("stage")
750
+ relevant = (method == "tools/list" and stage == "response") or (
751
+ method == "tools/call" and stage == "request")
752
+ if not relevant:
753
+ return {"findings": []} # not an ALR04 input; not a PASS either
754
+ payload = event.get("payload")
755
+ if not isinstance(payload, Mapping):
756
+ return {"findings": [_coverage_record(event, "payload_unavailable")]}
757
+
758
+ invalid = extra = 0
759
+ definitions: list[Mapping[str, Any]] = []
760
+ if method == "tools/list":
761
+ tools = payload.get("tools")
762
+ if not isinstance(tools, list):
763
+ return {"findings": [_coverage_record(event, "missing_or_invalid_tools_list")]}
764
+ for tool in tools[:MAX_TOOLS]:
765
+ if isinstance(tool, Mapping) and isinstance(tool.get("name"), str):
766
+ definitions.append(tool)
767
+ else:
768
+ invalid += 1
769
+ extra = max(0, len(tools) - MAX_TOOLS)
770
+ else:
771
+ cached, name = payload.get("tool_definition"), payload.get("tool")
772
+ if cached is None:
773
+ return {"findings": []} # ordinary calls are not schema evidence
774
+ if not isinstance(cached, Mapping) or not isinstance(name, str):
775
+ return {"findings": [_coverage_record(event, "invalid_cached_tool_definition")]}
776
+ definitions.append({**cached, "name": name})
777
+
778
+ server = str(event.get("server") or "")
779
+ siblings = [str(d["name"]) for d in definitions]
780
+ partial = bool(invalid or extra)
781
+ out: list[dict[str, Any]] = []
782
+ scanned = skipped = 0
783
+ outcomes = {"FAIL": 0, "INCONCLUSIVE": 0, "PASS": 0}
784
+ for definition in definitions:
785
+ if len(out) >= MAX_OUTPUT_RECORDS:
786
+ out.append(_coverage_record(event, "output_record_limit"))
787
+ break
788
+ name = str(definition["name"])
789
+ if method == "tools/call" and (server, name) in _tools_seen:
790
+ skipped += 1 # already scanned from a tools/list; do not report it twice
791
+ continue
792
+ if _remember(_scanned, (server, name, definition_hash(definition))):
793
+ skipped += 1
794
+ continue
795
+ _remember(_tools_seen, (server, name))
796
+ records, worst = scan_tool(definition, server, siblings, event, policy, partial)
797
+ out.extend(records)
798
+ outcomes[worst] += 1
799
+ scanned += 1
800
+ if invalid:
801
+ out.append(_coverage_record(event, "invalid_tool_definition", skipped=invalid))
802
+ if extra:
803
+ out.append(_coverage_record(event, "tool_count_limit", skipped=extra))
804
+ if scanned:
805
+ out.append({
806
+ "gate_id": GATE_ID, "rule_id": RULE_SUMMARY, "family": "scan_summary",
807
+ "entity": {"server": safe(server), "tool": ""}, "record_id": _pointer(event),
808
+ "outcome": "FAIL" if outcomes["FAIL"] else
809
+ "INCONCLUSIVE" if outcomes["INCONCLUSIVE"] else "PASS",
810
+ "is_finding": False, "suppressed": False, "severity": "info", "score": 0.0,
811
+ "coverage": 1.0, "tools_scanned": scanned, "tools_failed": outcomes["FAIL"],
812
+ "tools_inconclusive": outcomes["INCONCLUSIVE"], "tools_clear": outcomes["PASS"],
813
+ "tools_already_scanned": skipped, "owasp": OWASP, "evidence": [], "evidence_count": 0,
814
+ "message": "Scan summary for this event.",
815
+ })
816
+ return {"findings": out}