@passioncode-ai/fabric-agent-adapter 0.72.0 → 0.73.0
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.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +841 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent_schema.py +496 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +91 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/service-channel-agent-extension.schema.json +700 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +21 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +15 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""The probe's channel-agent rule (FAA-197): an agent that answers people on a channel declares
|
|
2
|
+
extensions/channel-agent/0.1 (Fabric Agent Contract DEC-0150) under provider.extensions of its manifest, or, for a
|
|
3
|
+
service with no manifest, under extensions of its well-known answer (CA-1). A port of the contract's
|
|
4
|
+
`src/channel-agent.ts` at the pin: channelAgentRules (FAC-SEM-162 to 165) and channelAgentView, held to the contract's
|
|
5
|
+
own vectors (test/fixtures/contract/channel-agent-vectors.json); the schema half is check_service_channel_agent_schema.py.
|
|
6
|
+
|
|
7
|
+
Strings are read as ECMAScript holds them where the contract counts or indexes UTF-16 units (an astral character is
|
|
8
|
+
two), and as code points where its regular expressions carry the `u` flag. No finding's message carries a declared
|
|
9
|
+
value: the instancePath places it (CA-5). Not a kit module (the probe has no Node twin). Standard library only."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ipaddress
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import unicodedata
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
+
from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
|
|
21
|
+
from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, _url_parts, raw_values, secret_argument, secret_url, secret_value # noqa: E402
|
|
22
|
+
import check_service_channel_agent_schema as cas # noqa: E402
|
|
23
|
+
|
|
24
|
+
KEY = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1"
|
|
25
|
+
#: Any version of the key: one other than 0.1 is not read until a decision moves it (CA-7).
|
|
26
|
+
KEY_PREFIX = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/"
|
|
27
|
+
KEY_TOKEN = KEY.replace("~", "~0").replace("/", "~1")
|
|
28
|
+
CHANNEL_AGENT_PATHS = {"manifest": "/provider/extensions/" + KEY_TOKEN, "well-known": "/extensions/" + KEY_TOKEN}
|
|
29
|
+
TOKEN_SLOT = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}/[a-z][a-z0-9-]{0,31}/[A-Z][A-Z0-9_]{0,63}\Z")
|
|
30
|
+
NAME_CHARACTERS = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
|
|
31
|
+
SECRET_FINDINGS = 64
|
|
32
|
+
PATH_DEPTH = 64
|
|
33
|
+
DETECTOR_CODE_POINTS = 4097
|
|
34
|
+
LONGEST_STRING = cas.LONGEST_STRING
|
|
35
|
+
MESSAGE_ERRORS = 10
|
|
36
|
+
SECRET_BUDGET = 1_000_000
|
|
37
|
+
VISIT_COST = 16
|
|
38
|
+
PIECE_COST = 8 * VISIT_COST
|
|
39
|
+
ARG_PIECE_COST = 4
|
|
40
|
+
KEY_REREAD = 255
|
|
41
|
+
LONG_RUN_FLOOR = 40
|
|
42
|
+
SK_FLOOR = 16
|
|
43
|
+
PREFIX_FLOORS = ((re.compile(r"gh[pousr]_", re.I | re.A), 20), (re.compile(r"xox[abposr]-", re.I | re.A), 10),
|
|
44
|
+
(re.compile(r"akia", re.I | re.A), 16), (re.compile(r"glpat-", re.I | re.A), 20))
|
|
45
|
+
EM_DASH = "\u2014"
|
|
46
|
+
#: The 2 MiB cap Fabric Dashboards' probe puts on a well-known answer (until the contract's CT-223), and "near" it.
|
|
47
|
+
WELL_KNOWN_CAP = 2 * 1024 * 1024
|
|
48
|
+
WELL_KNOWN_NEAR = WELL_KNOWN_CAP * 3 // 4
|
|
49
|
+
|
|
50
|
+
_SK = re.compile(r"sk-", re.I | re.A)
|
|
51
|
+
_ESCAPE_BEFORE = re.compile(r"(?:%25[0-9A-Fa-f]{2}|%[0-9A-Fa-f]{2}|\\x[0-9A-Fa-f]{2}|\\u[0-9A-Fa-f]{4}|\\[0-7]{2,3}|\\[^\n\r\u2028\u2029])\Z")
|
|
52
|
+
_ESC_BEFORE = re.compile(r"(?:\x1b|\\x1[bB]|\\0?33|\\u001[bB]|\\e)\Z")
|
|
53
|
+
_SEPARATOR = re.compile("[%s=/\\\\]" % JS_SPACE)
|
|
54
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
|
|
55
|
+
_HIGH_ESCAPE = re.compile(r"%[89A-Fa-f][0-9A-Fa-f]")
|
|
56
|
+
_RELATIVE_REF = re.compile(r"(?![/~])(?![A-Za-z][A-Za-z0-9+.-]*:)(?!(?:[^/]*/)*\.\.(?:/|\Z))[A-Za-z0-9._:/-]+\Z")
|
|
57
|
+
_OPAQUE_ID = re.compile(r"[A-Za-z0-9_-]{8,128}\Z")
|
|
58
|
+
_ASTRAL = re.compile("[\U00010000-\U0010ffff]")
|
|
59
|
+
_ID_MEMBERS = frozenset(("id", "code", "lead_agent", "owner", "decision", "answered_signal", "target", "spend_record", "project", "side"))
|
|
60
|
+
# Unicode's Default_Ignorable_Code_Point (DerivedCoreProperties), which Python's unicodedata does not carry.
|
|
61
|
+
_IGNORABLE = ((0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F),
|
|
62
|
+
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
|
|
63
|
+
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF))
|
|
64
|
+
_FILLERS = frozenset("\u115f\u1160\u3164\uffa0\u2800\ufffc")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def utf16(text: str) -> str:
|
|
68
|
+
"""The text as ECMAScript's UTF-16 units, one Python character each (an astral character is a surrogate pair)."""
|
|
69
|
+
if not _ASTRAL.search(text):
|
|
70
|
+
return text
|
|
71
|
+
return "".join(c if ord(c) < 0x10000 else chr(0xD800 + ((ord(c) - 0x10000) >> 10)) + chr(0xDC00 + ((ord(c) - 0x10000) & 0x3FF))
|
|
72
|
+
for c in text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _blank_char(char: str) -> bool:
|
|
76
|
+
code = ord(char)
|
|
77
|
+
return (char in JS_SPACE or char in _FILLERS or unicodedata.category(char) in ("Cf", "Cc", "Zs", "Zl", "Zp")
|
|
78
|
+
or any(low <= code <= high for low, high in _IGNORABLE))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def blank(text: str) -> bool:
|
|
82
|
+
"""Nothing left after white space, \\p{Cf}, \\p{Cc}, \\p{Z}, \\p{Default_Ignorable_Code_Point} and the fillers."""
|
|
83
|
+
return all(_blank_char(char) for char in text)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Frame:
|
|
87
|
+
__slots__ = ("container", "keys", "next", "at", "depth", "name", "parent", "collapsed", "role")
|
|
88
|
+
|
|
89
|
+
def __init__(self, container: Any, at: str, depth: int, name: Optional[str], parent: Optional[str], collapsed: bool, role: Optional[str]):
|
|
90
|
+
self.container = container
|
|
91
|
+
self.keys = None if isinstance(container, list) else js_keys(container)
|
|
92
|
+
self.next = 0
|
|
93
|
+
self.at, self.depth, self.name, self.parent, self.collapsed, self.role = at, depth, name, parent, collapsed, role
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _Spot:
|
|
97
|
+
__slots__ = ("frame", "key", "is_name")
|
|
98
|
+
|
|
99
|
+
def __init__(self, frame: Optional[_Frame], key: str, is_name: bool):
|
|
100
|
+
self.frame, self.key, self.is_name = frame, key, is_name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_ROLES = {"root": {"projects": "projects"}, "project": {"evals": "evals", "tickets": "tickets", "handoff_queues": "queues", "channels": "channels"},
|
|
104
|
+
"channel": {"texts": "texts"}}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _role_of(holder: Optional[str], key: str, index: bool) -> Optional[str]:
|
|
108
|
+
if holder == "texts":
|
|
109
|
+
return "texts"
|
|
110
|
+
if index:
|
|
111
|
+
return {"projects": "project", "queues": "queue", "channels": "channel"}.get(holder or "")
|
|
112
|
+
return _ROLES.get(holder or "", {}).get(key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def walk(value: Any, root: str, token_of: Callable[[_Frame, str, bool], Optional[str]], visit: Callable[[str, _Spot, Callable[[_Spot], str]], None],
|
|
116
|
+
stop: Callable[[], bool] = lambda: False, on_enter: Callable[[], None] = lambda: None) -> None:
|
|
117
|
+
"""Every member name and string value under `value`, with an explicit stack of frames (the contract's `walk`)."""
|
|
118
|
+
def token(frame: _Frame, key: str) -> Optional[str]:
|
|
119
|
+
return None if frame.collapsed or frame.depth >= PATH_DEPTH else token_of(frame, key, frame.keys is None)
|
|
120
|
+
|
|
121
|
+
def place(spot: _Spot) -> str:
|
|
122
|
+
frame = spot.frame
|
|
123
|
+
if frame is None:
|
|
124
|
+
return root
|
|
125
|
+
written = None if spot.is_name else token(frame, spot.key)
|
|
126
|
+
return frame.at if written is None else "%s/%s" % (frame.at, written)
|
|
127
|
+
|
|
128
|
+
if isinstance(value, str) and not stop():
|
|
129
|
+
visit(value, _Spot(None, "", False), place)
|
|
130
|
+
if not isinstance(value, (list, dict)):
|
|
131
|
+
if not isinstance(value, str):
|
|
132
|
+
on_enter()
|
|
133
|
+
return
|
|
134
|
+
on_enter()
|
|
135
|
+
stack = [_Frame(value, root, 0, None, None, False, "root")]
|
|
136
|
+
while stack and not stop():
|
|
137
|
+
frame = stack[-1]
|
|
138
|
+
length = len(frame.container) if frame.keys is None else len(frame.keys)
|
|
139
|
+
if frame.next >= length:
|
|
140
|
+
stack.pop()
|
|
141
|
+
continue
|
|
142
|
+
i = frame.next
|
|
143
|
+
frame.next += 1
|
|
144
|
+
index = frame.keys is None
|
|
145
|
+
key = str(i) if index else frame.keys[i]
|
|
146
|
+
child = frame.container[i] if index else frame.container[key]
|
|
147
|
+
if not index:
|
|
148
|
+
visit(key, _Spot(frame, key, True), place)
|
|
149
|
+
if isinstance(child, str):
|
|
150
|
+
visit(child, _Spot(frame, key, False), place)
|
|
151
|
+
elif isinstance(child, (list, dict)):
|
|
152
|
+
on_enter()
|
|
153
|
+
if len(child) == 0:
|
|
154
|
+
continue
|
|
155
|
+
written = token(frame, key)
|
|
156
|
+
stack.append(_Frame(child, frame.at if written is None else "%s/%s" % (frame.at, written), frame.depth + 1, key, frame.name,
|
|
157
|
+
written is None, _role_of(frame.role, key, index)))
|
|
158
|
+
else:
|
|
159
|
+
on_enter()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def oversize(value: Any) -> List[str]:
|
|
163
|
+
"""What is refused before the schema runs (CA-2): widths, and a string or name over LONGEST_STRING code points."""
|
|
164
|
+
names = cas.schema_names()
|
|
165
|
+
long: Dict[str, None] = {}
|
|
166
|
+
|
|
167
|
+
def visit(text: str, spot: _Spot, place: Callable[[_Spot], str]) -> None:
|
|
168
|
+
if len(text) > LONGEST_STRING:
|
|
169
|
+
long["maxLength at %s (at most %d code points)" % (place(spot) or "/", LONGEST_STRING)] = None
|
|
170
|
+
walk(value, "", lambda frame, key, index: cas.placed_token(frame.name, frame.parent, key, index, names, cas.is_none(frame.container)), visit)
|
|
171
|
+
return cas.widths(value) + list(long)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extensions_at(doc: Dict[str, Any], site: str) -> Optional[Dict[str, Any]]:
|
|
175
|
+
holder = doc.get("provider") if site == "manifest" else doc
|
|
176
|
+
return holder["extensions"] if isinstance(holder, dict) and isinstance(holder.get("extensions"), dict) else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def schema_view(doc: Any, site: str) -> Dict[str, Any]:
|
|
180
|
+
"""The schema's verdict on the declaration at one site (CA-1, CA-2), before the reader's rules."""
|
|
181
|
+
extensions = _extensions_at(doc, site) if isinstance(doc, dict) else None
|
|
182
|
+
if extensions is None or KEY not in extensions:
|
|
183
|
+
return {"declared": False}
|
|
184
|
+
value = extensions[KEY]
|
|
185
|
+
refused = oversize(value)
|
|
186
|
+
errors = refused if refused else cas.schema_errors(value)
|
|
187
|
+
if errors:
|
|
188
|
+
return {"declared": True, "valid": False, "errors": errors}
|
|
189
|
+
return {"declared": True, "valid": True, "standard": value["standard"], "projects": value["projects"]}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# The detector (FAC-SEM-101's, fabric_secrets.py) and what reading with it costs.
|
|
193
|
+
|
|
194
|
+
def head(text: str) -> str:
|
|
195
|
+
"""The first DETECTOR_CODE_POINTS code points: all the detector reads."""
|
|
196
|
+
return text[:DETECTOR_CODE_POINTS]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_as_url(read: str) -> bool:
|
|
200
|
+
"""A string's head is read as a URL when, leading C0 controls and spaces stripped and tab, line feed and carriage
|
|
201
|
+
return removed, it is ASCII only, holds no percent-escape of a byte at or above 0x80 and opens with a scheme (CA-5)."""
|
|
202
|
+
text = re.sub(r"[\t\n\r]", "", re.sub(r"\A[\x00-\x20]+", "", read))
|
|
203
|
+
return text.isascii() and not _HIGH_ESCAPE.search(text) and bool(_SCHEME.match(text))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _url_reading(read: str) -> bool:
|
|
207
|
+
"""secretUrl where read_as_url admits the head, secretArgument otherwise. The kit's secret_url also reads a URL the
|
|
208
|
+
WHATWG parser refuses (the safe direction for its own callers); the contract's `new URL` throws there and finds only
|
|
209
|
+
what secretArgument finds, so a refused URL is read by secret_argument alone here."""
|
|
210
|
+
if not read_as_url(read):
|
|
211
|
+
return secret_argument(read)
|
|
212
|
+
return secret_argument(read) or (url_parts(read) is not None and secret_url(read))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def shaped_read(read: str) -> bool:
|
|
216
|
+
return secret_value(read) or _url_reading(read)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _key_shaped(text: str) -> bool:
|
|
220
|
+
read = head(text)
|
|
221
|
+
return bool(JWT_SHAPE.search(read) or SECRET_VALUE.search(read))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def plain_name(name: str) -> bool:
|
|
225
|
+
return bool(NAME_CHARACTERS.match(name)) and not re.search(r"[0-9]{7}", re.sub(r"[-_]", "", name)) and not shaped_read(head(name))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _relative_ref(text: str) -> bool:
|
|
229
|
+
return 1 <= len(text) <= 256 and bool(_RELATIVE_REF.match(text))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_from(text: str, start: int) -> int:
|
|
233
|
+
end = start
|
|
234
|
+
while end < len(text) and (text[end].isascii() and (text[end].isalnum() or text[end] in "_-")):
|
|
235
|
+
end += 1
|
|
236
|
+
return end
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def jwt_rereads(text: str) -> int:
|
|
240
|
+
"""From each `eyJ`, its base64url run, `eyJ` included, and the dot and the run after it when the run ends at one."""
|
|
241
|
+
cost, reach, run_end = 0, 0, 0
|
|
242
|
+
at = text.find("eyJ")
|
|
243
|
+
while at >= 0:
|
|
244
|
+
if run_end <= at:
|
|
245
|
+
run_end = _run_from(text, at)
|
|
246
|
+
reach = _run_from(text, run_end + 1) if run_end < len(text) and text[run_end] == "." else run_end
|
|
247
|
+
cost += reach - at
|
|
248
|
+
at = text.find("eyJ", at + 1)
|
|
249
|
+
return cost
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _units(text: str, limit: int, start: int) -> int:
|
|
253
|
+
return max(0, min(limit, len(text) - start))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _colour_before(text: str, at: int) -> bool:
|
|
257
|
+
if at < 1 or text[at - 1] != "m":
|
|
258
|
+
return False
|
|
259
|
+
start = at - 2
|
|
260
|
+
while start >= 0 and text[start] in "0123456789;:":
|
|
261
|
+
start -= 1
|
|
262
|
+
return start >= 0 and text[start] == "[" and bool(_ESC_BEFORE.search(text[max(0, start - 6):start]))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def key_rereads(text: str) -> int:
|
|
266
|
+
"""From each `sk-` at a boundary up to KEY_REREAD units, SK_FLOOR after one elsewhere, a floor after each other prefix."""
|
|
267
|
+
cost = 0
|
|
268
|
+
match = _SK.search(text)
|
|
269
|
+
while match:
|
|
270
|
+
at = match.start()
|
|
271
|
+
before = text[at - 1] if at > 0 else ""
|
|
272
|
+
boundary = (at == 0 or not (before.isascii() and before.isalnum()) or bool(_ESCAPE_BEFORE.search(text[max(0, at - 6):at]))
|
|
273
|
+
or _colour_before(text, at))
|
|
274
|
+
cost += _units(text, KEY_REREAD, at) if boundary else _units(text, SK_FLOOR, at + 3)
|
|
275
|
+
match = _SK.search(text, at + 1)
|
|
276
|
+
for prefix, floor in PREFIX_FLOORS:
|
|
277
|
+
match = prefix.search(text)
|
|
278
|
+
while match:
|
|
279
|
+
cost += _units(text, floor, match.end())
|
|
280
|
+
match = prefix.search(text, match.start() + 1)
|
|
281
|
+
return cost
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_rereads(text: str) -> int:
|
|
285
|
+
"""For each maximal base64 run shorter than LONG_RUN_FLOOR in each of three partitions, min(length, 39) per unit."""
|
|
286
|
+
def charge(run: int) -> int:
|
|
287
|
+
return run * min(run, LONG_RUN_FLOOR - 1) if run < LONG_RUN_FLOOR else 0
|
|
288
|
+
cost = whole = no_equals = no_slash = 0
|
|
289
|
+
i, length = 0, len(text)
|
|
290
|
+
while i <= length:
|
|
291
|
+
c = ord(text[i]) if i < length else -1
|
|
292
|
+
if 65 <= c <= 90 or 97 <= c <= 122 or 48 <= c <= 57 or c in (43, 95, 45):
|
|
293
|
+
whole += 1
|
|
294
|
+
no_equals += 1
|
|
295
|
+
no_slash += 1
|
|
296
|
+
elif c == 47:
|
|
297
|
+
whole += 1
|
|
298
|
+
no_equals += 1
|
|
299
|
+
cost += charge(no_slash)
|
|
300
|
+
no_slash = 0
|
|
301
|
+
elif c == 61:
|
|
302
|
+
whole += 1
|
|
303
|
+
cost += charge(no_equals) + charge(no_slash)
|
|
304
|
+
no_equals = no_slash = 0
|
|
305
|
+
else:
|
|
306
|
+
cost += charge(whole) + charge(no_equals) + charge(no_slash)
|
|
307
|
+
whole = no_equals = no_slash = 0
|
|
308
|
+
if 0xD800 <= c <= 0xDBFF and i + 1 < length and 0xDC00 <= ord(text[i + 1]) <= 0xDFFF:
|
|
309
|
+
i += 1
|
|
310
|
+
i += 1
|
|
311
|
+
return cost
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def argument_pieces(text: str) -> int:
|
|
315
|
+
return ARG_PIECE_COST * len(_SEPARATOR.findall(text))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _ipv4_number(part: str) -> Optional[int]:
|
|
319
|
+
if part == "":
|
|
320
|
+
return None
|
|
321
|
+
base, digits = 10, part
|
|
322
|
+
if len(part) >= 2 and part[:2] in ("0x", "0X"):
|
|
323
|
+
base, digits = 16, part[2:]
|
|
324
|
+
elif len(part) >= 2 and part[0] == "0":
|
|
325
|
+
base, digits = 8, part[1:]
|
|
326
|
+
if digits == "":
|
|
327
|
+
return 0
|
|
328
|
+
try:
|
|
329
|
+
return int(digits, base) if re.fullmatch({10: "[0-9]+", 16: "[0-9A-Fa-f]+", 8: "[0-7]+"}[base], digits) else None
|
|
330
|
+
except ValueError:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _host_ok(host: str, special: bool) -> bool:
|
|
335
|
+
"""Whether the WHATWG URL parser takes an ASCII host (its failures only: forbidden code points, IPv6, IPv4)."""
|
|
336
|
+
if host.startswith("["):
|
|
337
|
+
if not host.endswith("]") or "%" in host:
|
|
338
|
+
return False
|
|
339
|
+
try:
|
|
340
|
+
ipaddress.IPv6Address(host[1:-1])
|
|
341
|
+
return True
|
|
342
|
+
except ValueError:
|
|
343
|
+
return False
|
|
344
|
+
forbidden = "\x00\t\n\r #/:<>?@[\\]^|"
|
|
345
|
+
if not special:
|
|
346
|
+
return not any(c in forbidden for c in host)
|
|
347
|
+
try:
|
|
348
|
+
decoded = re.sub(r"%([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)), host).lower()
|
|
349
|
+
except ValueError:
|
|
350
|
+
return False
|
|
351
|
+
if decoded == "" or any(c in forbidden or c == "%" or ord(c) < 0x20 or ord(c) == 0x7F for c in decoded):
|
|
352
|
+
return False
|
|
353
|
+
# An `xn--` label is not decoded: Node's parser (the reference runs on it) takes every ASCII one.
|
|
354
|
+
parts = decoded.split(".")
|
|
355
|
+
if parts[-1] == "" and len(parts) > 1:
|
|
356
|
+
parts = parts[:-1]
|
|
357
|
+
last = parts[-1]
|
|
358
|
+
if not (last and (last.isdigit() or (_ipv4_number(last) is not None and last[:2] in ("0x", "0X")))):
|
|
359
|
+
return True
|
|
360
|
+
numbers = [_ipv4_number(part) for part in parts]
|
|
361
|
+
if len(parts) > 4 or any(n is None for n in numbers) or any(n > 255 for n in numbers[:-1]):
|
|
362
|
+
return False
|
|
363
|
+
return numbers[-1] < 256 ** (5 - len(numbers))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
_SPECIAL = ("ftp", "http", "https", "ws", "wss", "file")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def url_parts(text: str) -> Optional[Tuple[str, str]]:
|
|
370
|
+
"""`new URL(text)`'s search and hash for an ASCII string read as a URL, or None where the parser throws."""
|
|
371
|
+
text = re.sub(r"[\t\n\r]", "", text.strip("".join(map(chr, range(0x21)))))
|
|
372
|
+
scheme, _, rest = text.partition(":")
|
|
373
|
+
lowered = scheme.lower()
|
|
374
|
+
special = lowered in _SPECIAL
|
|
375
|
+
body = rest.partition("#")[0].partition("?")[0]
|
|
376
|
+
authority: Optional[str] = None
|
|
377
|
+
if special and lowered != "file":
|
|
378
|
+
authority = re.split(r"[/\\]", body.lstrip("/\\"), maxsplit=1)[0]
|
|
379
|
+
elif lowered == "file" and re.match(r"[/\\]{2}", body):
|
|
380
|
+
authority = re.split(r"[/\\]", body[2:], maxsplit=1)[0]
|
|
381
|
+
elif not special and body.startswith("//"):
|
|
382
|
+
authority = body[2:].partition("/")[0]
|
|
383
|
+
if authority is not None and lowered == "file":
|
|
384
|
+
# A file host takes no user information and no port: the whole authority is the host.
|
|
385
|
+
if authority and authority.lower() != "localhost" and not _host_ok(authority, True):
|
|
386
|
+
return None
|
|
387
|
+
elif authority is not None:
|
|
388
|
+
userinfo, at, hostport = authority.rpartition("@")
|
|
389
|
+
bracket = hostport.rfind("]")
|
|
390
|
+
colon = hostport.rfind(":")
|
|
391
|
+
host, port = (hostport[:colon], hostport[colon + 1:]) if colon > bracket else (hostport, "")
|
|
392
|
+
if port and (not port.isdigit() or int(port) > 65535):
|
|
393
|
+
return None
|
|
394
|
+
if at and hostport == "":
|
|
395
|
+
return None
|
|
396
|
+
if host == "" and special and lowered != "file":
|
|
397
|
+
return None
|
|
398
|
+
if host and not _host_ok(host, special):
|
|
399
|
+
return None
|
|
400
|
+
_, query, fragment = _url_parts(text)
|
|
401
|
+
has_query = "?" in rest.partition("#")[0]
|
|
402
|
+
has_fragment = "#" in rest
|
|
403
|
+
return ("?" + query if has_query and query else "", "#" + fragment if has_fragment and fragment else "")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def detector_cost(whole: str, is_head: bool = False) -> int:
|
|
407
|
+
"""The UTF-16 units FAC-SEM-165's detector may read in a string's head (the contract's detectorCost)."""
|
|
408
|
+
text = whole if is_head else head(whole)
|
|
409
|
+
units = utf16(text)
|
|
410
|
+
cost = len(units) + jwt_rereads(units) + key_rereads(units) + run_rereads(units) + argument_pieces(units)
|
|
411
|
+
if not read_as_url(text):
|
|
412
|
+
return cost
|
|
413
|
+
cost += PIECE_COST
|
|
414
|
+
parts = url_parts(text)
|
|
415
|
+
if parts is None:
|
|
416
|
+
return cost
|
|
417
|
+
for part in parts:
|
|
418
|
+
cost += ARG_PIECE_COST * part.count("&")
|
|
419
|
+
for name, values in raw_values(part[1:]).items():
|
|
420
|
+
name_units = len(utf16(name))
|
|
421
|
+
upper = bool(re.search(r"[A-Z]", name))
|
|
422
|
+
for value in values:
|
|
423
|
+
value_units = utf16(value)
|
|
424
|
+
cost += PIECE_COST + name_units + len(value_units) + jwt_rereads(value_units) + key_rereads(value_units) + run_rereads(value_units)
|
|
425
|
+
if value != "" and upper:
|
|
426
|
+
cost += name_units * (name_units + 1) // 2
|
|
427
|
+
return cost
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class Budget:
|
|
431
|
+
"""What is left of FAC-SEM-165's reading for one document, shared by every copy of its declaration."""
|
|
432
|
+
|
|
433
|
+
def __init__(self) -> None:
|
|
434
|
+
self.left = SECRET_BUDGET
|
|
435
|
+
self.unread = False
|
|
436
|
+
self.named = 0
|
|
437
|
+
self.more = 0
|
|
438
|
+
self.unread_at: Optional[str] = None
|
|
439
|
+
self.copies: set = set()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _finding(code: str, at: str, message: str) -> Dict[str, str]:
|
|
443
|
+
return {"code": code, "instancePath": at, "message": message}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def secrets(value: Any, root: str, budget: Budget) -> List[Dict[str, str]]:
|
|
447
|
+
"""FAC-SEM-165: no member name and no string of the declaration is shaped like a credential (CA-5)."""
|
|
448
|
+
findings: List[Dict[str, str]] = []
|
|
449
|
+
names = cas.schema_names()
|
|
450
|
+
more = [0]
|
|
451
|
+
last: Dict[str, Any] = {"frame": None, "key": "", "plain": False}
|
|
452
|
+
|
|
453
|
+
def flag(at: str, what: str, text: str, ident: bool) -> None:
|
|
454
|
+
if budget.named < SECRET_FINDINGS:
|
|
455
|
+
budget.named += 1
|
|
456
|
+
rename = "; an id that starts with sk- is read as a key; rename it" if ident and re.match(r"sk-", text, re.I | re.A) else ""
|
|
457
|
+
findings.append(_finding("FAC-SEM-165", at, "%s is shaped like a credential: a token is named by its vault slot, never written%s (DEC-0150 CA-5)" % (what, rename)))
|
|
458
|
+
else:
|
|
459
|
+
budget.more += 1
|
|
460
|
+
more[0] += 1
|
|
461
|
+
|
|
462
|
+
def afford(read: str) -> bool:
|
|
463
|
+
if budget.unread:
|
|
464
|
+
return False
|
|
465
|
+
cost = (0 if read == "" else detector_cost(read, True)) + VISIT_COST
|
|
466
|
+
if cost > budget.left:
|
|
467
|
+
budget.unread, budget.unread_at = True, root
|
|
468
|
+
return False
|
|
469
|
+
budget.left -= cost
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
def token_of(frame: _Frame, key: str, index: bool) -> Optional[str]:
|
|
473
|
+
if index:
|
|
474
|
+
return cas.placed_token(frame.name, frame.parent, key, index, names)
|
|
475
|
+
plain = last["frame"] is frame and last["key"] == key and last["plain"]
|
|
476
|
+
return cas.placed_token(frame.name, frame.parent, key, index, names, cas.is_none(frame.container)) if plain else None
|
|
477
|
+
|
|
478
|
+
def in_texts(frame: Optional[_Frame]) -> bool:
|
|
479
|
+
return frame is not None and frame.role == "texts"
|
|
480
|
+
|
|
481
|
+
def visit(text: str, spot: _Spot, place: Callable[[_Spot], str]) -> None:
|
|
482
|
+
read = head(text)
|
|
483
|
+
if spot.is_name:
|
|
484
|
+
last.update(frame=spot.frame, key=text, plain=False)
|
|
485
|
+
if not afford(read):
|
|
486
|
+
return
|
|
487
|
+
is_shaped = shaped_read(read)
|
|
488
|
+
last["plain"] = not is_shaped and bool(NAME_CHARACTERS.match(text)) and not re.search(r"[0-9]{7}", re.sub(r"[-_]", "", text))
|
|
489
|
+
if is_shaped:
|
|
490
|
+
flag(place(spot), "a member's name", text, not in_texts(spot.frame))
|
|
491
|
+
return
|
|
492
|
+
if not afford(read):
|
|
493
|
+
return
|
|
494
|
+
role = spot.frame.role if spot.frame is not None else None
|
|
495
|
+
key = spot.key
|
|
496
|
+
if role == "tickets" and key == "token_slot" and TOKEN_SLOT.match(text):
|
|
497
|
+
found = any(secret_value(segment) for segment in text.split("/"))
|
|
498
|
+
elif ((role == "evals" and key == "suite") or (role == "tickets" and key == "outbox")) and _relative_ref(text):
|
|
499
|
+
found = _url_reading(read)
|
|
500
|
+
elif role == "queue" and key == "recommendation" and _OPAQUE_ID.match(text):
|
|
501
|
+
found = _key_shaped(text)
|
|
502
|
+
else:
|
|
503
|
+
found = shaped_read(read)
|
|
504
|
+
if found:
|
|
505
|
+
flag(place(spot), "a value", text, key in _ID_MEMBERS and not in_texts(spot.frame))
|
|
506
|
+
|
|
507
|
+
def on_enter() -> None:
|
|
508
|
+
if budget.unread:
|
|
509
|
+
return
|
|
510
|
+
if VISIT_COST > budget.left:
|
|
511
|
+
budget.unread, budget.unread_at = True, root
|
|
512
|
+
return
|
|
513
|
+
budget.left -= VISIT_COST
|
|
514
|
+
|
|
515
|
+
walk(value, root, token_of, visit, lambda: budget.unread, on_enter)
|
|
516
|
+
if more[0] > 0:
|
|
517
|
+
findings.append(_finding("FAC-SEM-165", root, "and %d more names or values shaped like a credential (DEC-0150 CA-5)" % more[0]))
|
|
518
|
+
return findings
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def summary(budget: Budget) -> List[Dict[str, str]]:
|
|
522
|
+
"""The document's closing FAC-SEM-165 finding, once: that the rest was not read."""
|
|
523
|
+
if not budget.unread:
|
|
524
|
+
return []
|
|
525
|
+
at = budget.unread_at or ""
|
|
526
|
+
fix = ("this copy is not read by a reader and should be removed" if at in budget.copies
|
|
527
|
+
else "a reader treats the declaration as not declared; declare fewer or shorter texts or names")
|
|
528
|
+
return [_finding("FAC-SEM-165", at, "reading the declarations for credentials takes more than %d UTF-16 units of reading; %s (DEC-0150 CA-5)" % (SECRET_BUDGET, fix))]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def misplaced(doc: Dict[str, Any], budget: Budget) -> List[Dict[str, str]]:
|
|
532
|
+
"""FAC-SEM-162 on a manifest: the key where a reader does not read it (the manifest's own extensions, a capability's)."""
|
|
533
|
+
findings: List[Dict[str, str]] = []
|
|
534
|
+
|
|
535
|
+
def flag(extensions: Dict[str, Any], at: str) -> None:
|
|
536
|
+
root = "%s/%s" % (at, KEY_TOKEN)
|
|
537
|
+
findings.append(_finding("FAC-SEM-162", root, "a reader reads a manifest's channel-agent declaration under provider.extensions only, so it is not read here (DEC-0150 CA-1)"))
|
|
538
|
+
budget.copies.add(root)
|
|
539
|
+
findings.extend(secrets(extensions[KEY], root, budget))
|
|
540
|
+
if isinstance(doc.get("extensions"), dict) and KEY in doc["extensions"]:
|
|
541
|
+
flag(doc["extensions"], "/extensions")
|
|
542
|
+
for capability, i in indexed_objects(doc.get("capabilities")):
|
|
543
|
+
if isinstance(capability.get("extensions"), dict) and KEY in capability["extensions"]:
|
|
544
|
+
flag(capability["extensions"], "/capabilities/%d/extensions" % i)
|
|
545
|
+
return findings
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class _Cap:
|
|
549
|
+
def __init__(self) -> None:
|
|
550
|
+
self.named = 0
|
|
551
|
+
self.more = 0
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _capped(findings: List[Dict[str, str]], cap: _Cap, code: str, at: Callable[[], str], message: str) -> None:
|
|
555
|
+
if cap.named < SECRET_FINDINGS:
|
|
556
|
+
cap.named += 1
|
|
557
|
+
findings.append(_finding(code, at(), message))
|
|
558
|
+
else:
|
|
559
|
+
cap.more += 1
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def closing(code: str, cap: _Cap, root: str) -> List[Dict[str, str]]:
|
|
563
|
+
if cap.more == 0:
|
|
564
|
+
return []
|
|
565
|
+
what = ("repeated ids or decisions not named (DEC-0150 CA-3)" if code == "FAC-SEM-163" else
|
|
566
|
+
"places that owe a text, a recommendation or a spend record, or carry a blank or refused text (DEC-0150 CA-4)")
|
|
567
|
+
return [_finding(code, root, "and %d more %s" % (cap.more, what))]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _repeats(items: List[Tuple[Dict[str, Any], int]], member: str) -> List[int]:
|
|
571
|
+
seen: List[Any] = []
|
|
572
|
+
found = []
|
|
573
|
+
for item, i in items:
|
|
574
|
+
value = item.get(member)
|
|
575
|
+
if value in seen:
|
|
576
|
+
found.append(i)
|
|
577
|
+
seen.append(value)
|
|
578
|
+
return found
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def references(project: Dict[str, Any], at: str, cap: _Cap) -> List[Dict[str, str]]:
|
|
582
|
+
"""FAC-SEM-163: ids once, and every channel held by a decision names a recorded one (CA-3)."""
|
|
583
|
+
findings: List[Dict[str, str]] = []
|
|
584
|
+
|
|
585
|
+
def flag(path: str, message: str) -> None:
|
|
586
|
+
_capped(findings, cap, "FAC-SEM-163", lambda: at + path, "%s (DEC-0150 CA-3)" % message)
|
|
587
|
+
for i in _repeats(indexed_objects(project["taxonomy"].get("codes")), "code"):
|
|
588
|
+
flag("/taxonomy/codes/%d/code" % i, "this category code repeats an earlier one")
|
|
589
|
+
for i in _repeats(indexed_objects(project.get("tiers")), "code"):
|
|
590
|
+
flag("/tiers/%d/code" % i, "this tier code repeats an earlier one")
|
|
591
|
+
channels = indexed_objects(project.get("channels"))
|
|
592
|
+
for i in _repeats(channels, "id"):
|
|
593
|
+
flag("/channels/%d/id" % i, "this channel id repeats an earlier channel's")
|
|
594
|
+
source_ids: List[Any] = []
|
|
595
|
+
for member, message in (("sources", "this source id repeats an earlier source's"),
|
|
596
|
+
("declined_sources", "this declined source's id repeats a source read or declined before it")):
|
|
597
|
+
for source, i in indexed_objects(project.get(member)):
|
|
598
|
+
if source.get("id") in source_ids:
|
|
599
|
+
flag("/%s/%d/id" % (member, i), message)
|
|
600
|
+
source_ids.append(source.get("id"))
|
|
601
|
+
for i in _repeats(indexed_objects(project.get("handoff_queues")), "id"):
|
|
602
|
+
flag("/handoff_queues/%d/id" % i, "this hand-off queue id repeats an earlier queue's")
|
|
603
|
+
decisions = indexed_objects(project.get("recorded_decisions"))
|
|
604
|
+
for i in _repeats(decisions, "id"):
|
|
605
|
+
flag("/recorded_decisions/%d/id" % i, "this decision id repeats an earlier recorded decision's")
|
|
606
|
+
recorded = [decision.get("id") for decision, _ in decisions]
|
|
607
|
+
for channel, i in channels:
|
|
608
|
+
if channel.get("decision") is None:
|
|
609
|
+
if channel.get("replies_enabled") is False:
|
|
610
|
+
flag("/channels/%d/decision" % i, "a channel with replies off names the recorded decision that holds it")
|
|
611
|
+
elif channel["decision"] not in recorded:
|
|
612
|
+
flag("/channels/%d/decision" % i, "this channel's decision is not one recorded_decisions lists")
|
|
613
|
+
return findings
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def obligations(project: Dict[str, Any], at: str, cap: _Cap) -> List[Dict[str, str]]:
|
|
617
|
+
"""FAC-SEM-164: what a channel, a queue and the budgets owe (CA-4)."""
|
|
618
|
+
findings: List[Dict[str, str]] = []
|
|
619
|
+
|
|
620
|
+
def flag(path: Callable[[], str], message: str) -> None:
|
|
621
|
+
_capped(findings, cap, "FAC-SEM-164", lambda: at + path(), "%s (DEC-0150 CA-4)" % message)
|
|
622
|
+
for channel, i in indexed_objects(project.get("channels")):
|
|
623
|
+
texts = channel["texts"]
|
|
624
|
+
replies, holding = channel.get("replies_enabled") is True, channel.get("holding_reply") is True
|
|
625
|
+
final = texts.get("final")
|
|
626
|
+
if replies and holding and "holding" not in texts:
|
|
627
|
+
flag(lambda i=i: "/channels/%d/texts/holding" % i, "a channel that sends a holding reply declares the holding text")
|
|
628
|
+
if replies and "final" not in texts:
|
|
629
|
+
flag(lambda i=i: "/channels/%d/texts/final" % i, "a channel that replies declares the final text")
|
|
630
|
+
if replies and holding and isinstance(final, dict) and final.get("none") == "not_used":
|
|
631
|
+
flag(lambda i=i: "/channels/%d/texts/final" % i, "a channel that sends a holding reply owes a final reply, so its final text is not one it does not use")
|
|
632
|
+
if channel.get("kind") == "store_review" and channel.get("clarifying_question") is True:
|
|
633
|
+
flag(lambda i=i: "/channels/%d/clarifying_question" % i, "a store review gets one public reply and no clarifying question")
|
|
634
|
+
for which in ("holding", "final"):
|
|
635
|
+
if which == "final" and cas.is_none(final):
|
|
636
|
+
continue
|
|
637
|
+
entries = texts.get(which) or {}
|
|
638
|
+
for language in js_keys(entries):
|
|
639
|
+
text = entries[language]
|
|
640
|
+
|
|
641
|
+
def place(i: int = i, which: str = which, language: str = language) -> str:
|
|
642
|
+
return "/channels/%d/texts/%s%s" % (i, which, "/*" if plain_name(language) else "")
|
|
643
|
+
if blank(text):
|
|
644
|
+
flag(place, "a declared text is blank, and nothing empty is sent")
|
|
645
|
+
elif EM_DASH in text:
|
|
646
|
+
flag(place, "a declared text carries U+2014, which a send's checks refuse")
|
|
647
|
+
for tier, i in indexed_objects(project.get("tiers")):
|
|
648
|
+
label = tier["label"]
|
|
649
|
+
if blank(label):
|
|
650
|
+
flag(lambda i=i: "/tiers/%d/label" % i, "a tier's label is blank, and nothing empty is shown")
|
|
651
|
+
elif EM_DASH in label:
|
|
652
|
+
flag(lambda i=i: "/tiers/%d/label" % i, "a tier's label carries U+2014, which a send's checks refuse")
|
|
653
|
+
for queue, i in indexed_objects(project.get("handoff_queues")):
|
|
654
|
+
if queue.get("answered_signal") == "none" and "recommendation" not in queue:
|
|
655
|
+
flag(lambda i=i: "/handoff_queues/%d/recommendation" % i, "a hand-off queue with no answered signal names the recommendation to its owner")
|
|
656
|
+
if queue.get("answered_signal") != "none" and "recommendation" in queue:
|
|
657
|
+
flag(lambda i=i: "/handoff_queues/%d/recommendation" % i, "a hand-off queue with an answered signal needs no recommendation, and a reader does not read this one")
|
|
658
|
+
evals = project["evals"]
|
|
659
|
+
if (evals["judge_budget_usd"] > 0 or evals["replay_budget_usd"] > 0) and evals.get("spend_record") is None:
|
|
660
|
+
flag(lambda: "/evals/spend_record", "a budget above 0 names the usage report's limit it is spent under; without one it reads 0")
|
|
661
|
+
return findings
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _repeat(at: str, cap: _Cap) -> List[Dict[str, str]]:
|
|
665
|
+
findings: List[Dict[str, str]] = []
|
|
666
|
+
_capped(findings, cap, "FAC-SEM-163", lambda: at + "/project", "this project repeats an earlier entry's (DEC-0150 CA-3)")
|
|
667
|
+
return findings
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def channel_agent_rules(doc: Any, site: str = "manifest") -> List[Dict[str, str]]:
|
|
671
|
+
"""DEC-0150's checks on a manifest or a well-known answer: FAC-SEM-162 a misplaced (manifest only) or invalid
|
|
672
|
+
declaration, FAC-SEM-165 credential shapes, FAC-SEM-163 and FAC-SEM-164 per project of a valid one."""
|
|
673
|
+
if not isinstance(doc, dict):
|
|
674
|
+
return []
|
|
675
|
+
budget = Budget()
|
|
676
|
+
view = schema_view(doc, site)
|
|
677
|
+
root = CHANNEL_AGENT_PATHS[site]
|
|
678
|
+
read = secrets(_extensions_at(doc, site)[KEY], root, budget) if view["declared"] else []
|
|
679
|
+
findings = misplaced(doc, budget) if site == "manifest" else []
|
|
680
|
+
if not view["declared"]:
|
|
681
|
+
return findings + summary(budget)
|
|
682
|
+
findings += read + summary(budget)
|
|
683
|
+
if not view["valid"]:
|
|
684
|
+
named = ", ".join(cas.message_places(view["errors"][:MESSAGE_ERRORS]))
|
|
685
|
+
more = " and %d more" % (len(view["errors"]) - MESSAGE_ERRORS) if len(view["errors"]) > MESSAGE_ERRORS else ""
|
|
686
|
+
findings.append(_finding("FAC-SEM-162", root, "the channel-agent declaration fails its schema: %s%s; a reader treats it as not declared and "
|
|
687
|
+
"reports it (DEC-0150 CA-2). The grammars are in docs/specification/channel-agent.md#the-declaration" % (named, more)))
|
|
688
|
+
return findings
|
|
689
|
+
seen: List[Any] = []
|
|
690
|
+
repeated, owing, owed = _Cap(), _Cap(), []
|
|
691
|
+
for i, project in enumerate(view["projects"]):
|
|
692
|
+
at = "%s/projects/%d" % (root, i)
|
|
693
|
+
if project.get("project") in seen:
|
|
694
|
+
findings += _repeat(at, repeated)
|
|
695
|
+
seen.append(project.get("project"))
|
|
696
|
+
findings += references(project, at, repeated)
|
|
697
|
+
owed += obligations(project, at, owing)
|
|
698
|
+
return findings + closing("FAC-SEM-163", repeated, root) + owed + closing("FAC-SEM-164", owing, root)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def view_line(finding: Dict[str, str]) -> str:
|
|
702
|
+
"""A view's line: the rule, the place and the finding's reason, its message without the decision it cites."""
|
|
703
|
+
return "%s at %s: %s" % (finding["code"], finding["instancePath"] or "/", re.sub(r" \(DEC-0150 CA-[0-9]\)\Z", "", finding["message"]))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def schema_line(error: str) -> str:
|
|
707
|
+
parts = re.match(r"(\S+) at (/\S*)(.*)\Z", error)
|
|
708
|
+
return "FAC-SEM-162 at /: %s" % error if parts is None else "FAC-SEM-162 at %s: %s%s" % (parts.group(2), parts.group(1), parts.group(3))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _once(lines: List[str]) -> List[str]:
|
|
712
|
+
return list(dict.fromkeys(lines))
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def channel_agent_view(doc: Any, site: str = "manifest") -> Dict[str, Any]:
|
|
716
|
+
"""The reader of extensions/channel-agent/0.1 at one site (CA-1, CA-2, CA-5): declared, reported and errors, and for
|
|
717
|
+
a read and valid declaration its standard, projects and FAC-SEM-164's findings as obligations."""
|
|
718
|
+
view = schema_view(doc, site)
|
|
719
|
+
budget = Budget()
|
|
720
|
+
if not view["declared"]:
|
|
721
|
+
copies = misplaced(doc, budget) + summary(budget) if site == "manifest" and isinstance(doc, dict) else []
|
|
722
|
+
return {"declared": False, "reported": True, "errors": _once([view_line(f) for f in copies])} if copies else {"declared": False, "reported": False, "errors": []}
|
|
723
|
+
value = _extensions_at(doc, site)[KEY]
|
|
724
|
+
credentials = [view_line(f) for f in secrets(value, "", budget) + summary(budget)]
|
|
725
|
+
if not view["valid"]:
|
|
726
|
+
lines = [schema_line(error) for error in view["errors"][:SECRET_FINDINGS]]
|
|
727
|
+
if len(view["errors"]) > SECRET_FINDINGS:
|
|
728
|
+
lines.append("FAC-SEM-162 at /: and %d more" % (len(view["errors"]) - SECRET_FINDINGS))
|
|
729
|
+
return {"declared": False, "reported": True, "errors": _once(lines + credentials)}
|
|
730
|
+
repeated, owing = _Cap(), _Cap()
|
|
731
|
+
blocking: List[Dict[str, str]] = []
|
|
732
|
+
owed: List[Dict[str, str]] = []
|
|
733
|
+
seen: List[Any] = []
|
|
734
|
+
for i, project in enumerate(view["projects"]):
|
|
735
|
+
at = "/projects/%d" % i
|
|
736
|
+
if project.get("project") in seen:
|
|
737
|
+
blocking += _repeat(at, repeated)
|
|
738
|
+
seen.append(project.get("project"))
|
|
739
|
+
blocking += references(project, at, repeated)
|
|
740
|
+
owed += obligations(project, at, owing)
|
|
741
|
+
blocking += closing("FAC-SEM-163", repeated, "")
|
|
742
|
+
errors = credentials + [view_line(f) for f in blocking]
|
|
743
|
+
if errors:
|
|
744
|
+
return {"declared": False, "reported": True, "errors": _once(errors)}
|
|
745
|
+
return {"declared": True, "reported": False, "errors": [], "standard": view["standard"], "projects": view["projects"],
|
|
746
|
+
"obligations": _once([view_line(f) for f in owed + closing("FAC-SEM-164", owing, "")])}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# The probe's rule (D1 to D4 of the 0.73.0 brief).
|
|
750
|
+
|
|
751
|
+
def _other_keys(doc: Any) -> List[str]:
|
|
752
|
+
"""Places of a channel-agent key of another version, which a reader does not read (CA-7)."""
|
|
753
|
+
found: List[str] = []
|
|
754
|
+
if not isinstance(doc, dict):
|
|
755
|
+
return found
|
|
756
|
+
holders = [("/provider/extensions", (doc.get("provider") or {}).get("extensions") if isinstance(doc.get("provider"), dict) else None),
|
|
757
|
+
("/extensions", doc.get("extensions"))]
|
|
758
|
+
holders += [("/capabilities/%d/extensions" % i, c.get("extensions")) for c, i in indexed_objects(doc.get("capabilities"))]
|
|
759
|
+
for at, extensions in holders:
|
|
760
|
+
if isinstance(extensions, dict):
|
|
761
|
+
for key in extensions:
|
|
762
|
+
if key.startswith(KEY_PREFIX) and key != KEY:
|
|
763
|
+
version = key[len(KEY_PREFIX):]
|
|
764
|
+
found.append("%s (version %s)" % (at, version if re.fullmatch(r"[0-9]{1,4}(?:\.[0-9]{1,4}){0,3}", version) else "not written"))
|
|
765
|
+
return found
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _shown(findings: List[Dict[str, str]], limit: int = 5) -> str:
|
|
769
|
+
text = "; ".join("%s at %s" % (f["code"], f["instancePath"] or "/") for f in findings[:limit])
|
|
770
|
+
return text + ("; and %d more" % (len(findings) - limit) if len(findings) > limit else "")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _credential_shapes(findings: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
|
774
|
+
"""FAC-SEM-165's findings of a credential's shape, not its budget run-out: they fail the rule wherever they sit."""
|
|
775
|
+
return [f for f in findings if f["code"] == "FAC-SEM-165" and "shaped like a credential" in f["message"]]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _size_note(well_known_bytes: int) -> str:
|
|
779
|
+
return ("the well-known answer is %d bytes, %s the 2 MiB cap of Fabric Dashboards' probe: keep a large declaration in "
|
|
780
|
+
"the manifest (CT-223)" % (well_known_bytes, "over" if well_known_bytes > WELL_KNOWN_CAP else "near"))
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def declaration_verdict(manifest: Optional[Dict[str, Any]], manifest_named: bool, manifest_problem: str,
|
|
784
|
+
well_known: Optional[Dict[str, Any]], well_known_bytes: Optional[int] = None) -> Tuple[str, str]:
|
|
785
|
+
"""`channel-agent.declaration`: PASS for a declaration read and free of findings; FAIL with its first findings when
|
|
786
|
+
the contract's reader still reads it as declared (FAC-SEM-164's obligations) or a string is shaped like a credential
|
|
787
|
+
(FAC-SEM-165), in the read declaration or in any copy beside it; NOT_RUN, never FAIL, when nothing is declared or the reader reads it as not declared (D3): a key
|
|
788
|
+
where a reader does not read it, another version's key, a copy in the well-known answer of a service with a
|
|
789
|
+
manifest (D1), or a declaration the schema or FAC-SEM-163 refuses (an older form)."""
|
|
790
|
+
notes: List[str] = []
|
|
791
|
+
wk_has = isinstance(well_known, dict) and KEY in (_extensions_at(well_known, "well-known") or {})
|
|
792
|
+
if manifest_named and manifest is None:
|
|
793
|
+
copy_shapes = _credential_shapes(channel_agent_rules(well_known, "well-known")) if wk_has else []
|
|
794
|
+
if wk_has:
|
|
795
|
+
notes.append("the well-known answer's copy is not read: a service with a manifest declares there only (CA-1)")
|
|
796
|
+
if well_known_bytes is not None and well_known_bytes >= WELL_KNOWN_NEAR:
|
|
797
|
+
notes.append(_size_note(well_known_bytes))
|
|
798
|
+
if copy_shapes:
|
|
799
|
+
return "FAIL", "the well-known answer's copy holds %d string(s) shaped like a credential: %s (DEC-0150 CA-5)%s" % (
|
|
800
|
+
len(copy_shapes), _shown(copy_shapes), (" (%s)" % "; ".join(notes)) if notes else "")
|
|
801
|
+
return "NOT_RUN", "the manifest the descriptor names is not read (%s); a service with a manifest declares there only (CA-1)%s" % (
|
|
802
|
+
manifest_problem, (" (%s)" % "; ".join(notes)) if notes else "")
|
|
803
|
+
site, doc = ("manifest", manifest) if manifest is not None else ("well-known", well_known)
|
|
804
|
+
root = CHANNEL_AGENT_PATHS[site]
|
|
805
|
+
findings = channel_agent_rules(doc, site)
|
|
806
|
+
mine = [f["instancePath"] == root or f["instancePath"].startswith(root + "/") for f in findings]
|
|
807
|
+
own = [f for f, ours in zip(findings, mine) if ours]
|
|
808
|
+
others = [f for f, ours in zip(findings, mine) if not ours]
|
|
809
|
+
if others:
|
|
810
|
+
notes.append("not read, reported: %s" % _shown(others))
|
|
811
|
+
other_keys = _other_keys(doc) + (_other_keys(well_known) if site == "manifest" else [])
|
|
812
|
+
if other_keys:
|
|
813
|
+
notes.append("a channel-agent key of another version is not read (CA-7): %s" % ", ".join(other_keys[:5]))
|
|
814
|
+
copy: List[Dict[str, str]] = []
|
|
815
|
+
if site == "manifest" and wk_has:
|
|
816
|
+
copy = channel_agent_rules(well_known, "well-known")
|
|
817
|
+
notes.append("the well-known answer's copy is ignored: a service with a manifest is read from it only (CA-1)%s"
|
|
818
|
+
% ("; the copy has %d finding(s): %s" % (len(copy), _shown(copy, 3)) if copy else ""))
|
|
819
|
+
if wk_has and well_known_bytes is not None and well_known_bytes >= WELL_KNOWN_NEAR:
|
|
820
|
+
notes.append(_size_note(well_known_bytes))
|
|
821
|
+
tail = (" (%s)" % "; ".join(notes)) if notes else ""
|
|
822
|
+
declared = isinstance(doc, dict) and KEY in (_extensions_at(doc, site) or {})
|
|
823
|
+
where = "the manifest's provider.extensions" if site == "manifest" else "the well-known answer's extensions"
|
|
824
|
+
shapes = _credential_shapes(others + copy)
|
|
825
|
+
if shapes: # a credential's shape in a copy no reader reads still sits in a document the service publishes (CA-5)
|
|
826
|
+
return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s" % (
|
|
827
|
+
len(shapes), _shown(shapes), tail)
|
|
828
|
+
if declared and own:
|
|
829
|
+
if not channel_agent_view(doc, site)["declared"] and not _credential_shapes(own):
|
|
830
|
+
# D3: what the contract's reader reads as not declared (an older form, a repeat, a dangling reference) is
|
|
831
|
+
# reported while producers move to this revision; a credential's shape (FAC-SEM-165) always fails, while a
|
|
832
|
+
# declaration too large to read for credentials is read as not declared.
|
|
833
|
+
return "NOT_RUN", "read as not declared, never a failure while producers move to this revision: %d finding(s) on the declaration in %s: %s%s" % (
|
|
834
|
+
len(own), where, _shown(own), tail)
|
|
835
|
+
return "FAIL", "%d finding(s) on the declaration in %s: %s%s" % (len(own), where, _shown(own), tail)
|
|
836
|
+
if declared:
|
|
837
|
+
projects = schema_view(doc, site).get("projects") or []
|
|
838
|
+
return "PASS", "declared in %s: channel-agent-standard/1, %d project(s), no finding on it%s" % (where, len(projects), tail)
|
|
839
|
+
if notes:
|
|
840
|
+
return "NOT_RUN", "read as not declared, never a failure while producers move to this revision%s" % tail
|
|
841
|
+
return "NOT_RUN", "declares no %s in %s: not a channel agent" % (KEY, where)
|