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.
- agent_latch/__init__.py +3 -0
- agent_latch/action_summary.py +211 -0
- agent_latch/agent_code.py +487 -0
- agent_latch/astutil.py +20 -0
- agent_latch/cli.py +500 -0
- agent_latch/findings.py +65 -0
- agent_latch/gates/__init__.py +17 -0
- agent_latch/gates/alr04.py +816 -0
- agent_latch/manifest.py +256 -0
- agent_latch/oversight.py +225 -0
- agent_latch/owasp.py +105 -0
- agent_latch/prompts.py +90 -0
- agent_latch/report.py +241 -0
- agent_latch/rules.py +495 -0
- agent_latch/runtime/__init__.py +34 -0
- agent_latch/runtime/cli.py +314 -0
- agent_latch/runtime/demo.py +78 -0
- agent_latch/runtime/demo_server.py +54 -0
- agent_latch/runtime/events.py +91 -0
- agent_latch/runtime/importer.py +543 -0
- agent_latch/runtime/middleware.py +201 -0
- agent_latch/runtime/normalizer.py +106 -0
- agent_latch/runtime/payloads.py +190 -0
- agent_latch/runtime/proxy.py +44 -0
- agent_latch/runtime/redact.py +100 -0
- agent_latch/runtime/results.py +274 -0
- agent_latch/runtime/sqlite_store.py +230 -0
- agent_latch/runtime/trace.py +199 -0
- agent_latch/suppress.py +169 -0
- agent_latch/taint.py +264 -0
- agent_latch/yamlload.py +43 -0
- agentlatch_security-0.4.0.dist-info/METADATA +391 -0
- agentlatch_security-0.4.0.dist-info/RECORD +37 -0
- agentlatch_security-0.4.0.dist-info/WHEEL +5 -0
- agentlatch_security-0.4.0.dist-info/entry_points.txt +3 -0
- agentlatch_security-0.4.0.dist-info/licenses/LICENSE +21 -0
- agentlatch_security-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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}
|