@passioncode-ai/passioncode 0.2.45 → 0.2.46
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 +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -15,7 +15,9 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
15
15
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
16
16
|
|
|
17
17
|
from fabric_base import ServiceError # noqa: E402
|
|
18
|
-
from fabric_secrets import
|
|
18
|
+
from fabric_secrets import ( # noqa: E402
|
|
19
|
+
JWT_SHAPE, _BEARER_INSIDE as _BEARER, _long_secret, credential_name, raw_values, secret_argument, secret_value,
|
|
20
|
+
)
|
|
19
21
|
|
|
20
22
|
CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
|
|
21
23
|
KINDS = ("service", "model", "connector", "store", "channel")
|
|
@@ -161,45 +163,9 @@ def _long_run(value: str) -> bool:
|
|
|
161
163
|
return False
|
|
162
164
|
|
|
163
165
|
|
|
164
|
-
# The shared detector (DEC-0044) as the contract's ECMAScript reads it:
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
_NOT_LINE_END = "[^\n\r\u2028\u2029]"
|
|
168
|
-
_SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
|
|
169
|
-
r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (_JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
|
|
170
|
-
_BEARER = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % _JS_SPACE,
|
|
171
|
-
re.IGNORECASE | re.ASCII)
|
|
172
|
-
_PATH_LIKE = re.compile(r"(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
|
|
173
|
-
_ARGUMENT_SPLIT = re.compile("[%s=]" % _JS_SPACE)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _credential_name(name: str) -> bool:
|
|
177
|
-
"""fabric_secrets.credential_name with ECMAScript's whitespace in the word split (U+FEFF splits a name there)."""
|
|
178
|
-
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
179
|
-
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
|
|
180
|
-
split = [w.lower() for w in re.split("[%s._-]+" % _JS_SPACE, spaced) if w]
|
|
181
|
-
words = split + [w[:-1] for w in split if len(w) > 2 and w.endswith("s")]
|
|
182
|
-
if any(w in CREDENTIAL_WORDS for w in words) or (len(split) == 1 and any(w in CREDENTIAL_PAIRS for w in words)):
|
|
183
|
-
return True
|
|
184
|
-
return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _secret_value(value: str) -> bool:
|
|
188
|
-
return bool(JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _long_secret(value))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _path_like(piece: str) -> bool:
|
|
192
|
-
return bool(_PATH_LIKE.match(piece)) or (
|
|
193
|
-
bool(re.search(r"[\\/]", piece))
|
|
194
|
-
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}\Z", piece))
|
|
195
|
-
or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", segment) for segment in re.split(r"[\\/]", piece))))
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def _secret_argument(value: str) -> bool:
|
|
199
|
-
if JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _BEARER.search(value):
|
|
200
|
-
return True
|
|
201
|
-
return any(any(_long_secret(part) for part in re.split(r"[\\/]", piece)) if _path_like(piece) else _long_secret(piece)
|
|
202
|
-
for piece in _ARGUMENT_SPLIT.split(value))
|
|
166
|
+
# The shared detector (DEC-0044) as the contract's ECMAScript reads it: fabric_secrets reads so since 0.56.0 (FAA-136;
|
|
167
|
+
# before, this module kept its own copy, blind reading of 0.44.0, finding 1).
|
|
168
|
+
_credential_name, _secret_value, _secret_argument = credential_name, secret_value, secret_argument
|
|
203
169
|
|
|
204
170
|
|
|
205
171
|
_SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
|
|
@@ -335,14 +301,15 @@ def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
|
|
|
335
301
|
|
|
336
302
|
def _raw_values(query: str) -> Dict[str, List[str]]:
|
|
337
303
|
"""fabric_secrets.raw_values with the name read as URLSearchParams reads it: one leading `?` of the raw piece is
|
|
338
|
-
dropped before decoding (`??token` names `?token`, `%3Ftoken` names `?token`; round-3 reading).
|
|
304
|
+
dropped before decoding (`??token` names `?token`, `%3Ftoken` names `?token`; round-3 reading). raw_values drops
|
|
305
|
+
that one `?` itself since 0.56.0, so the piece's name is handed over as it stands (seam r8)."""
|
|
339
306
|
values: Dict[str, List[str]] = {}
|
|
340
307
|
for piece in query.split("&"):
|
|
341
308
|
if piece == "":
|
|
342
309
|
continue
|
|
343
310
|
at = piece.find("=")
|
|
344
311
|
raw_name = piece if at < 0 else piece[:at]
|
|
345
|
-
merged = raw_values(raw_name
|
|
312
|
+
merged = raw_values(raw_name)
|
|
346
313
|
name = next(iter(merged), "")
|
|
347
314
|
values.setdefault(name, []).extend(raw_values("x" + piece[at:])["x"] if at >= 0 else [""])
|
|
348
315
|
return values
|
|
@@ -624,9 +624,45 @@ def _loopback(host: str) -> bool:
|
|
|
624
624
|
if host == "localhost":
|
|
625
625
|
return True
|
|
626
626
|
try:
|
|
627
|
-
|
|
627
|
+
address = ipaddress.ip_address(host)
|
|
628
628
|
except ValueError:
|
|
629
629
|
return False
|
|
630
|
+
# A zone (`::1%eth0`) is no loopback: urllib decodes `%3A80` in it into another address (0.56.0 seam r13).
|
|
631
|
+
# Nor an IPv4-mapped address, which Python 3.13+ counts as loopback and 3.9 and Node do not (0.56.0 seam r14).
|
|
632
|
+
return address.is_loopback and not getattr(address, "scope_id", None) and not getattr(address, "ipv4_mapped", None)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# fabric_http._URL_ENDS (this module may not import fabric_http; test_fabric_delegation pins them equal).
|
|
636
|
+
_URL_ENDS = "\t\n\x0b\x0c\r\x1c\x1d\x1e\x1f "
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _source_authority_ok(parts: urllib.parse.SplitResult) -> bool:
|
|
640
|
+
"""A key set URL's host and port as urllib will dial them and `new URL` accepts them (fabric_http._authority_ok's
|
|
641
|
+
rule, which this module may not import): no `%` (urllib decodes it), backslash, space or C0, DEL or C1 control; a
|
|
642
|
+
bracketed host that is an IPv6 address; any other host without `:`, `[` or `]`; a port of ASCII digits up to
|
|
643
|
+
65535; a host at all. So `http://[::1]%2Eevil.example/` and `https://[1.2.3.4]/` (both read by Python 3.9's
|
|
644
|
+
urlsplit) are refused (0.56.0 unit r13, r14)."""
|
|
645
|
+
netloc = parts.netloc
|
|
646
|
+
if any(c in "\\%" or ord(c) <= 0x20 or 0x7f <= ord(c) <= 0x9f or c.isspace() for c in netloc):
|
|
647
|
+
return False
|
|
648
|
+
if netloc.startswith("["):
|
|
649
|
+
close = netloc.find("]")
|
|
650
|
+
try:
|
|
651
|
+
ipaddress.IPv6Address(netloc[1:close] if close > 0 else "")
|
|
652
|
+
except ValueError:
|
|
653
|
+
return False
|
|
654
|
+
port = netloc[close + 1:]
|
|
655
|
+
if port and not re.fullmatch(r":[0-9]*", port):
|
|
656
|
+
return False
|
|
657
|
+
else:
|
|
658
|
+
host, _, port = netloc.partition(":")
|
|
659
|
+
if not host or any(c in "[]:" for c in host + port) or not re.fullmatch(r"[0-9]*", port):
|
|
660
|
+
return False
|
|
661
|
+
try:
|
|
662
|
+
parts.port # noqa: B018 - raises on a port above 65535
|
|
663
|
+
except ValueError:
|
|
664
|
+
return False
|
|
665
|
+
return bool(parts.hostname)
|
|
630
666
|
|
|
631
667
|
|
|
632
668
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
@@ -669,7 +705,8 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
|
|
|
669
705
|
|
|
670
706
|
def _fetch(source: str) -> Any:
|
|
671
707
|
if source.startswith(("https://", "http://")):
|
|
672
|
-
|
|
708
|
+
# Cut at the first `#`, as `new URL` and the check at construction read it (urllib cuts at the last one).
|
|
709
|
+
request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
|
|
673
710
|
deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
|
|
674
711
|
try:
|
|
675
712
|
with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
|
|
@@ -702,9 +739,21 @@ class JwksCache:
|
|
|
702
739
|
if not isinstance(source, str) or not source:
|
|
703
740
|
raise ValueError("the key set needs a source")
|
|
704
741
|
if source.startswith(("https://", "http://")):
|
|
705
|
-
|
|
706
|
-
|
|
742
|
+
# The trailing end trimmed as urllib trims it when it reads (a leading one fails the scheme test above, as
|
|
743
|
+
# in 0.55.0 and in Node); inside, what urllib would send and http.client refuse on every read is refused
|
|
744
|
+
# (0.56.0 product r16, r17, r18, unit r16, r17). urlsplit's own refusals are said as the key set's.
|
|
745
|
+
sent = source.strip(_URL_ENDS)
|
|
746
|
+
try:
|
|
747
|
+
parts = urllib.parse.urlsplit(sent)
|
|
748
|
+
except ValueError:
|
|
749
|
+
raise ValueError("a key set URL's host does not parse") from None
|
|
750
|
+
if re.search(r"[\x00-\x20\x7f]", sent.partition("#")[0]) or not (parts.path + parts.query).isascii():
|
|
751
|
+
raise ValueError("a key set URL holds a space, an ASCII control character, or a character outside ASCII "
|
|
752
|
+
"in its path or query")
|
|
753
|
+
if "@" in parts.netloc: # an empty one too: urllib would dial `@127.0.0.1` (0.56.0 unit r14)
|
|
707
754
|
raise ValueError("a key set URL carries no credentials")
|
|
755
|
+
if not _source_authority_ok(parts):
|
|
756
|
+
raise ValueError("a key set URL's host does not parse")
|
|
708
757
|
if parts.scheme == "http" and not _loopback(parts.hostname or ""):
|
|
709
758
|
raise ValueError("a key set is read over https, or over http on loopback only")
|
|
710
759
|
elif "://" in source:
|
|
@@ -226,7 +226,11 @@ _SOURCE_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:(?:[A-Za-z0-9._~:/?#@!$&'()*
|
|
|
226
226
|
# Brackets belong to an IP-literal host only (RFC 3986 3.2.2); the literal is set aside before the character check.
|
|
227
227
|
_IP_LITERAL = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@\[\]]*@)?)\[[0-9A-Fa-f:.vV]+\]")
|
|
228
228
|
_URI_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@]*@)?(?P<host>\[[^\]]*\]|[^/?#:]*)(?::(?P<port>[^/?#]*))?")
|
|
229
|
-
|
|
229
|
+
# A special scheme (http, https, ws, wss, ftp) reads a user:password@ after any run of slashes, none included, as
|
|
230
|
+
# `new URL` does: https:bot:pw@host carries the password too (0.56.0 seam r7).
|
|
231
|
+
_URI_PASSWORD = re.compile(r"^(?:(?:[Hh][Tt][Tt][Pp][Ss]?|[Ww][Ss][Ss]?|[Ff][Tt][Pp]):[/\\]*[^/\\?#]*:[^/\\?#]*@"
|
|
232
|
+
# any other scheme: a backslash belongs to the user information (seam r8)
|
|
233
|
+
r"|(?![Hh][Tt][Tt][Pp][Ss]?:|[Ww][Ss][Ss]?:|[Ff][Tt][Pp]:)[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*:[^/?#]*@)")
|
|
230
234
|
|
|
231
235
|
|
|
232
236
|
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])\Z", re.ASCII)
|
|
@@ -303,10 +307,10 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
303
307
|
repository = source["repository"]
|
|
304
308
|
if not (isinstance(repository, str) and _SOURCE_URI.match(_IP_LITERAL.sub(r"\1h", repository, count=1))):
|
|
305
309
|
problems.append("source.repository is a URI (RFC 3986), such as https://github.com/owner/name")
|
|
310
|
+
elif _URI_PASSWORD.match(repository): # before the host: user information is a credential, said as one
|
|
311
|
+
problems.append("source.repository carries no password: it names the repository, never a credential")
|
|
306
312
|
elif not _authority_ok(repository):
|
|
307
313
|
problems.append("source.repository names a host, and a port of digits up to 65535 when it has one")
|
|
308
|
-
elif _URI_PASSWORD.match(repository):
|
|
309
|
-
problems.append("source.repository carries no password: it names the repository, never a credential")
|
|
310
314
|
places.append(("/source/repository", repository))
|
|
311
315
|
if "peer" in descriptor:
|
|
312
316
|
peer = descriptor.get("peer")
|
|
@@ -119,11 +119,13 @@ class JsonlEventLog:
|
|
|
119
119
|
continue
|
|
120
120
|
return out
|
|
121
121
|
|
|
122
|
-
def append(self, kind: str, level: str, text: str,
|
|
122
|
+
def append(self, kind: str, level: str, text: str, subject: Optional[Dict[str, str]] = None,
|
|
123
|
+
**extra: Any) -> Dict[str, Any]:
|
|
124
|
+
# `subject` is named so a caller that reads the signature (the board responder) knows this log takes one.
|
|
123
125
|
with self._lock:
|
|
124
126
|
rows = self._all()
|
|
125
127
|
next_id = int(rows[-1]["id"]) + 1 if rows else 1
|
|
126
|
-
event = make_event(next_id, now_iso(), kind, level, text, **extra)
|
|
128
|
+
event = make_event(next_id, now_iso(), kind, level, text, subject=subject, **extra)
|
|
127
129
|
rows.append(event)
|
|
128
130
|
rows = rows[-self.keep:]
|
|
129
131
|
atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py
CHANGED
|
@@ -113,24 +113,86 @@ def json_bytes(value: Any) -> bytes:
|
|
|
113
113
|
return json_stringify(value).encode("utf-8")
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
# What both urllib (str.strip when it builds a Request) and a WHATWG parser remove from a URL's ends. urllib also
|
|
117
|
+
# strips a Unicode space there, which `new URL` keeps (and percent-encodes): such a URL is refused (0.56.0 seam r19).
|
|
118
|
+
_URL_ENDS = "\t\n\x0b\x0c\r\x1c\x1d\x1e\x1f "
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _sendable(url: str, parts: urllib.parse.SplitResult) -> bool:
|
|
122
|
+
"""Whether urllib can send the URL as checked: no space or ASCII control character before its fragment (urlsplit
|
|
123
|
+
drops a tab or line break that urllib then sends), and nothing outside ASCII in the path or query, which
|
|
124
|
+
http.client cannot write in a request line (a host outside ASCII is sent in IDNA)."""
|
|
125
|
+
# The fragment is never sent: urllib_transport cuts the URL at its first `#`, as `new URL` does (urllib alone
|
|
126
|
+
# would cut at the last one), so only what precedes it is read (0.56.0 seam r18, r19).
|
|
127
|
+
return not re.search(r"[\x00-\x20\x7f]", url.partition("#")[0]) and (parts.path + parts.query).isascii()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _authority_ok(netloc: str) -> bool:
|
|
131
|
+
"""The host and port of an authority as a WHATWG URL accepts them, where urlsplit is laxer: a port of ASCII digits
|
|
132
|
+
only (Python 3.9's int() also takes `1_2`, `+1`, ` 12` and other scripts' digits), a bracketed host that is an IPv6
|
|
133
|
+
address with no zone, and no backslash, space, control character (C0, DEL or C1) or `%` (0.56.0 unit r11, seam r12, seam r13, product r20:
|
|
134
|
+
urllib percent-decodes the host before it connects, so `h.example%3A8443` would reach port 8443 and `[::1%3A80]`
|
|
135
|
+
another address than the one checked; Node refuses a zone and every forbidden code point it decodes)."""
|
|
136
|
+
hostport = netloc.rpartition("@")[2]
|
|
137
|
+
if any(c in "\\%" or ord(c) <= 0x20 or 0x7f <= ord(c) <= 0x9f or c.isspace() for c in hostport):
|
|
138
|
+
return False
|
|
139
|
+
if hostport.startswith("["):
|
|
140
|
+
close = hostport.find("]")
|
|
141
|
+
try:
|
|
142
|
+
ipaddress.IPv6Address(hostport[1:close])
|
|
143
|
+
except ValueError:
|
|
144
|
+
return False
|
|
145
|
+
port = hostport[close + 1:]
|
|
146
|
+
return port == "" or (port[:1] == ":" and re.fullmatch(r"[0-9]*", port[1:]) is not None)
|
|
147
|
+
host, _, port = hostport.partition(":")
|
|
148
|
+
# No `[` in an unbracketed host: Python 3.9 reads the host after one (`https://]@h.example[evil.example/` as
|
|
149
|
+
# evil.example), where 3.14 and `new URL` refuse it (0.56.0 unit r15).
|
|
150
|
+
return not any(c in "[]:" for c in host + port) and re.fullmatch(r"[0-9]*", port) is not None
|
|
151
|
+
|
|
152
|
+
|
|
116
153
|
def redact_url(url: str) -> str:
|
|
117
154
|
"""The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or
|
|
118
155
|
fragment, which is where keys and tokens ride."""
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
# What WHATWG URL parsing does first, as `new URL` does in the Node twin: trim C0 controls and space from the ends,
|
|
157
|
+
# then remove every tab, CR and LF, so ` https://u:pw@h` and `ht\ttps:u:pw@h` lose their password too (0.56.0
|
|
158
|
+
# seam r10; Python 3.9's urlsplit keeps a leading space in the scheme).
|
|
159
|
+
url = re.sub(r"[\t\n\r]", "", str(url).strip("".join(map(chr, range(0x21)))))
|
|
160
|
+
scheme, colon, rest = url.partition(":")
|
|
161
|
+
if colon and scheme.lower() in ("http", "https", "ws", "wss", "ftp"):
|
|
162
|
+
# A special scheme as WHATWG reads it: the authority starts after every slash or backslash that follows the
|
|
163
|
+
# colon, and a backslash before the query reads as a slash, so `https:u:pw@h` loses its password too (0.56.0
|
|
164
|
+
# seam r9; the Node twin reads it through `new URL`).
|
|
165
|
+
cut = min([at for at in (rest.find("?"), rest.find("#")) if at >= 0] or [len(rest)])
|
|
166
|
+
url = scheme + "://" + (rest[:cut].replace("\\", "/") + rest[cut:]).lstrip("/")
|
|
167
|
+
if not colon or not re.fullmatch(r"[A-Za-z][A-Za-z0-9+.-]*", scheme):
|
|
168
|
+
# No scheme as WHATWG reads one (`ht tps:`, `1ttps:`, a relative string): `new URL` refuses it too. Read here,
|
|
169
|
+
# not by urlsplit, whose scheme rule differs between 3.9 patch releases (0.56.0 hosted floors on 10f6f9d).
|
|
122
170
|
return "<unparseable url>"
|
|
123
|
-
|
|
171
|
+
if scheme.lower() in ("http", "https", "ws", "wss", "ftp"):
|
|
172
|
+
# A special scheme's authority is checked first, its shape as `new URL` checks it (a host, a port of ASCII
|
|
173
|
+
# digits up to 65535), so `ftp://[` and `ws://h:99999` are unparseable in both twins rather than `<scheme>:
|
|
174
|
+
# <redacted>` in one (0.56.0 unit r12, r13); on 3.9 a lax port would read as the path. A host name `new URL`
|
|
175
|
+
# refuses for its structure is not checked (FAA-187 (14)).
|
|
176
|
+
try:
|
|
177
|
+
parts = urllib.parse.urlsplit(url)
|
|
178
|
+
parts.port # noqa: B018 - raises on a port out of range
|
|
179
|
+
except ValueError:
|
|
180
|
+
return "<unparseable url>"
|
|
181
|
+
if not _authority_ok(parts.netloc) or not parts.hostname:
|
|
182
|
+
return "<unparseable url>"
|
|
183
|
+
if scheme.lower() not in ("http", "https"):
|
|
184
|
+
# Only http and https are sent; any other scheme (a misspelled `htps:` included) keeps no more than its name,
|
|
185
|
+
# since its user information may sit anywhere in the rest (0.56.0 product r10).
|
|
186
|
+
return scheme.lower() + ":<redacted>"
|
|
187
|
+
# The host and port were checked above (a password holding `/`, `https://u:pw/x@h`, reads as a bad port there).
|
|
188
|
+
host = parts.hostname
|
|
124
189
|
if ":" in host:
|
|
125
190
|
host = "[%s]" % host
|
|
126
|
-
|
|
127
|
-
port = parts.port
|
|
128
|
-
except ValueError:
|
|
129
|
-
port = None
|
|
191
|
+
port = parts.port
|
|
130
192
|
if port == {"http": 80, "https": 443}.get(parts.scheme.lower()):
|
|
131
193
|
port = None # as a WHATWG URL writes it, so both kits print one URL the same way
|
|
132
194
|
netloc = host.lower() + (":%d" % port if port is not None else "")
|
|
133
|
-
path = parts.path or
|
|
195
|
+
path = parts.path or "/"
|
|
134
196
|
return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
|
|
135
197
|
|
|
136
198
|
|
|
@@ -157,11 +219,23 @@ def check_url(url: str, *, insecure: bool = False) -> None:
|
|
|
157
219
|
"""Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
|
|
158
220
|
plain http to a host that is not loopback (unless `insecure`)."""
|
|
159
221
|
try:
|
|
160
|
-
|
|
222
|
+
# The ends are trimmed as urllib trims what it sends (and `new URL` too); a URL ending in a line break, which
|
|
223
|
+
# 0.55.0 sent, still passes (0.56.0 product r17).
|
|
224
|
+
if not isinstance(url, str):
|
|
225
|
+
raise TypeError("not a string")
|
|
226
|
+
sent = url.strip(_URL_ENDS)
|
|
227
|
+
parts = urllib.parse.urlsplit(sent)
|
|
161
228
|
host = parts.hostname
|
|
162
229
|
parts.port # noqa: B018 - raises on a bad port
|
|
230
|
+
if not _authority_ok(parts.netloc):
|
|
231
|
+
raise ValueError("authority")
|
|
163
232
|
except (ValueError, TypeError):
|
|
164
233
|
raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
|
|
234
|
+
if not _sendable(sent, parts):
|
|
235
|
+
# urllib would send it and http.client refuse every call, where the Node twin percent-encodes it (0.56.0
|
|
236
|
+
# product r16, r18, unit r16, r17); 0.55.0 retried and answered kind connection.
|
|
237
|
+
raise HttpError("the URL holds a space, an ASCII control character, or a character outside ASCII in its path "
|
|
238
|
+
"or query", url=redact_url(url), kind="refused")
|
|
165
239
|
if parts.scheme not in ("http", "https") or not host:
|
|
166
240
|
raise HttpError("only http and https URLs with a host are sent", url=redact_url(url), kind="refused")
|
|
167
241
|
if parts.username is not None or parts.password is not None:
|
|
@@ -275,7 +349,9 @@ def _opener(watchdog: _Watchdog) -> urllib.request.OpenerDirector:
|
|
|
275
349
|
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout_s: float,
|
|
276
350
|
max_bytes: int) -> Tuple[int, Dict[str, str], bytes]:
|
|
277
351
|
"""The default transport: urllib, no redirects followed, the body read to one byte past `max_bytes`."""
|
|
278
|
-
|
|
352
|
+
# Cut at the first `#`, as `new URL` and check_url read the URL: urllib keeps everything before the LAST one in
|
|
353
|
+
# the request line, so `/x#a#b` went out as `/x#a` (0.56.0 seam r19).
|
|
354
|
+
req = urllib.request.Request(url.partition("#")[0], data=body, method=method, headers=headers)
|
|
279
355
|
end = time.monotonic() + timeout_s # one deadline from the first byte sent, headers and body included
|
|
280
356
|
watchdog = _Watchdog(end)
|
|
281
357
|
try:
|
|
@@ -39,6 +39,8 @@ from fabric_page_links import ( # noqa: E402 - DEC-0123
|
|
|
39
39
|
|
|
40
40
|
from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
41
41
|
_days_from_civil,
|
|
42
|
+
UNREADABLE_PROJECT,
|
|
43
|
+
mandate_spend,
|
|
42
44
|
PROTOCOL,
|
|
43
45
|
EXTENSION_KEY,
|
|
44
46
|
MCP_REVISION,
|
|
@@ -511,20 +513,28 @@ class CallContext:
|
|
|
511
513
|
the mandate carries one) and asks nothing; the job goes on as the operator's decision. Otherwise the job waits
|
|
512
514
|
input_required on `request`, its message naming the part not covered (or `message`), and answers None; the step
|
|
513
515
|
is kept so an answer's `_meta.mandate` that covers it is read when input_actions is on. Any fault job_step answers
|
|
514
|
-
(mandate-capability-missing, MJ-1, or the server clock's now-unreadable) goes to on_fault under the job's
|
|
516
|
+
(mandate-capability-missing, MJ-1, or the server clock's now-unreadable) goes to on_fault under the job's
|
|
517
|
+
capability. The step is read under that capability, the name the job was created under (FAA-179), which is the
|
|
518
|
+
tool's own unless the handler created the job under another name with `server.jobs.create`."""
|
|
515
519
|
if self.server.jobs is None:
|
|
516
520
|
raise InteropError("This server has no job store.")
|
|
517
521
|
step: Dict[str, Any] = {"message": None}
|
|
522
|
+
stored = self.server.jobs.private(job_id).get("capability")
|
|
523
|
+
capability = stored if isinstance(stored, str) else self.tool
|
|
518
524
|
if self.server.agent_id is not None:
|
|
519
525
|
caller = (self.delegation or {}).get("caller")
|
|
520
|
-
|
|
521
|
-
|
|
526
|
+
carried = self.meta.get("mandate")
|
|
527
|
+
if carried is None and "mandate" in self.meta:
|
|
528
|
+
carried = {} # a null mandate is present and unreadable, as the answer reads it; never "none" (FAA-178)
|
|
529
|
+
step = job_step(carried, tier=self.tier, now=format_rfc3339(self.server._clock() * 1000),
|
|
530
|
+
self_id=self.server.agent_id, capability=capability, key=key, effect=effect, caller_agent=caller,
|
|
522
531
|
spend_usd=spend_usd, project=project)
|
|
523
532
|
if step["covered"]:
|
|
524
533
|
return step["record"]
|
|
525
|
-
self.server._fault(
|
|
526
|
-
|
|
527
|
-
|
|
534
|
+
self.server._fault(capability, step) # the start hears it as the answer does, under the same name (MJ-1)
|
|
535
|
+
spend = mandate_spend(spend_usd) # a NaN or infinity reads as no estimate, and json would write a bare NaN
|
|
536
|
+
kept = {"effect": effect, **({"spendUsd": spend} if spend is not None else {}),
|
|
537
|
+
**({"project": project if isinstance(project, str) else UNREADABLE_PROJECT} if project is not None else {})}
|
|
528
538
|
self.server.jobs.request_input(job_id, {key: request}, step["message"] or message, steps={key: kept})
|
|
529
539
|
return None
|
|
530
540
|
|
|
@@ -616,7 +626,7 @@ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
|
616
626
|
|
|
617
627
|
|
|
618
628
|
_UNREADABLE = object() # what a service's spend or project function answers when it raised
|
|
619
|
-
_UNREADABLE_PROJECT =
|
|
629
|
+
_UNREADABLE_PROJECT = UNREADABLE_PROJECT # never a member of a mandate's projects, so a scoped mandate refuses
|
|
620
630
|
|
|
621
631
|
|
|
622
632
|
class McpToolServer:
|
|
@@ -915,7 +925,8 @@ class McpToolServer:
|
|
|
915
925
|
if not inputs["ok"] and "invalid" in inputs:
|
|
916
926
|
return self._error(rid, -32602, inputs["invalid"])
|
|
917
927
|
if not inputs["ok"]:
|
|
918
|
-
self.
|
|
928
|
+
stored = self.jobs.private(str(arguments.get("id", ""))).get("capability")
|
|
929
|
+
self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
|
|
919
930
|
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
920
931
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
|
|
921
932
|
context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None}
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import hashlib
|
|
9
9
|
import json
|
|
10
|
+
import math
|
|
10
11
|
import re
|
|
11
12
|
import secrets
|
|
12
13
|
from typing import Any, Callable, Dict, List, Optional
|
|
@@ -336,16 +337,18 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
336
337
|
part not covered, or saying the mandate expired or cannot be read; never the operator's words or `source`.
|
|
337
338
|
With `fault` mandate-capability-missing beside it when the job's capability cannot read the step (MJ-1).
|
|
338
339
|
- {"covered": False, "message": None}: no mandate, or a T2 caller's (MD-5): ask as before; with `fault` when the
|
|
339
|
-
service's own clock does not read.
|
|
340
|
+
service's own clock does not read. None is no mandate: a call that carried `_meta.mandate: null` passes `{}`, so
|
|
341
|
+
it reads as mandate-malformed here as it does at the answer (FAA-178; McpToolServer does)."""
|
|
340
342
|
if mandate is None:
|
|
341
343
|
return {"covered": False, "message": None}
|
|
342
344
|
if not (isinstance(effect, str) and effect in EFFECT_CLASSES):
|
|
343
345
|
raise InteropError("A job's approval step names its effect class (one of %s; DEC-0125)." % ", ".join(EFFECT_CLASSES))
|
|
344
346
|
call: Dict[str, Any] = {"capability": capability, "action": key, "effect": effect}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
spend = mandate_spend(spend_usd) # read as action_gate reads it, so the start and the answer agree (FAA-139)
|
|
348
|
+
if spend is not None:
|
|
349
|
+
call["spendUsd"] = spend
|
|
350
|
+
if project is not None:
|
|
351
|
+
call["project"] = project if isinstance(project, str) else UNREADABLE_PROJECT
|
|
349
352
|
covers: Dict[str, Any] = {"mandate": mandate, "tier": tier, "now": now, "self": self_id, "call": call}
|
|
350
353
|
if isinstance(caller_agent, str) and caller_agent:
|
|
351
354
|
covers["callerAgent"] = caller_agent
|
|
@@ -368,6 +371,23 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
368
371
|
"message": said + ": answer it, or call fabric.job.get with a mandate that covers it in _meta.mandate."}
|
|
369
372
|
|
|
370
373
|
|
|
374
|
+
#: The project of a call whose project could not be read: never a member of a mandate's projects, so a mandate limited
|
|
375
|
+
#: to projects refuses it (FAA-139).
|
|
376
|
+
UNREADABLE_PROJECT = "\x00unreadable"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def mandate_spend(value: Any) -> Any:
|
|
380
|
+
"""A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
|
|
381
|
+
not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
|
|
382
|
+
ceiling refuses (fail closed). A finite number stays as given (25 stays 25)."""
|
|
383
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
384
|
+
return None
|
|
385
|
+
try:
|
|
386
|
+
return value if math.isfinite(float(value)) else None
|
|
387
|
+
except OverflowError:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
371
391
|
def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
|
|
372
392
|
elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
|
|
373
393
|
human_step_url: Optional[str] = None, now: Any = None, self_id: Optional[str] = None,
|
|
@@ -386,7 +406,9 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
386
406
|
decision, given `effect` (the action's effect class, required: without it the call is the service's fault
|
|
387
407
|
effect-missing), `caller_agent` (only an agent id the service authenticated: a delegation's caller, its own
|
|
388
408
|
per-agent credential; never a name the call carries), `spend_usd` (its own estimate), `project`, and `mandate_capability` (a job's kept step: the job's capability
|
|
389
|
-
the mandate is read under, DEC-0141 MJ-1; unusable, the refusal is operator-decision-required).
|
|
409
|
+
the mandate is read under, DEC-0141 MJ-1; unusable, the refusal is operator-decision-required). Both are read failing
|
|
410
|
+
closed, as McpToolServer reads them (FAA-139): a spend that is not a finite double is none (`mandate_spend`), a
|
|
411
|
+
project that is not a string is UNREADABLE_PROJECT, which a mandate limited to projects refuses. A covered call
|
|
390
412
|
answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
|
|
391
413
|
`self_id` the mandate is not read and the call is answered as before it existed."""
|
|
392
414
|
import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
|
|
@@ -398,6 +420,8 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
398
420
|
inputs[name] = value
|
|
399
421
|
if self_id is not None and isinstance(meta, dict) and "mandate" in meta:
|
|
400
422
|
inputs.update({"mandate": meta["mandate"], "self": self_id})
|
|
423
|
+
spend_usd = mandate_spend(spend_usd)
|
|
424
|
+
project = project if project is None or isinstance(project, str) else UNREADABLE_PROJECT
|
|
401
425
|
for name, value in (("effect", effect), ("callerAgent", caller_agent), ("spendUsd", spend_usd), ("project", project),
|
|
402
426
|
("mandateCapability", mandate_capability)):
|
|
403
427
|
if value is not None:
|
|
@@ -164,6 +164,12 @@ class Client:
|
|
|
164
164
|
kind="config")
|
|
165
165
|
if not (isinstance(retries, int) and 0 <= retries <= 5) or isinstance(retries, bool):
|
|
166
166
|
raise OpenRouterError("retries is an integer from 0 to 5", kind="config")
|
|
167
|
+
# A query or fragment would take `/chat/completions` (0.56.0 unit r19, FAA-187 (17)); refused before
|
|
168
|
+
# check_url, so a query that also holds a space is kind config here as on Node (product r27).
|
|
169
|
+
if not isinstance(base_url, str): # check_url's answer for a base that is not a string, as on Node (unit r28)
|
|
170
|
+
raise fabric_http.HttpError("the URL does not parse", url="<unparseable url>", kind="refused")
|
|
171
|
+
if "?" in base_url or "#" in base_url:
|
|
172
|
+
raise OpenRouterError("the base URL holds no query or fragment", kind="config")
|
|
167
173
|
fabric_http.check_url(base_url)
|
|
168
174
|
_check_attribution(project_id, None)
|
|
169
175
|
self.ceiling_usd = float(ceiling_usd)
|
|
@@ -171,7 +177,9 @@ class Client:
|
|
|
171
177
|
self.prices = {str(k): dict(v) for k, v in prices.items()}
|
|
172
178
|
self.key_env = key_env
|
|
173
179
|
self.window = window
|
|
174
|
-
|
|
180
|
+
# The base URL as check_url read it and urllib would send it: its ends trimmed, so `.../v1\n` is not sent
|
|
181
|
+
# as `.../v1\n/chat/completions` (0.56.0 seam r18; the Node twin trims the ends `new URL` does, product r20).
|
|
182
|
+
self.base_url = base_url.strip(fabric_http._URL_ENDS).rstrip("/")
|
|
175
183
|
self.attribution = attribution
|
|
176
184
|
self.project_id = project_id
|
|
177
185
|
self.unmeasured_call_usd = unmeasured_call_usd
|
|
@@ -111,6 +111,19 @@ def _scalar(value: Any) -> bool:
|
|
|
111
111
|
return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def _too_deep(key: str, member: Any) -> str:
|
|
115
|
+
"""The first place under a member that breaks the bound, for the refusal (FAA-122): `cited[0]` nests an object."""
|
|
116
|
+
if isinstance(member, (list, dict)) and len(member) > 32:
|
|
117
|
+
return ": it has %d entries" % len(member)
|
|
118
|
+
for inner, leaf in (enumerate(member) if isinstance(member, list) else member.items() if isinstance(member, dict) else ()):
|
|
119
|
+
if not _scalar(leaf):
|
|
120
|
+
place = "%s[%d]" % (key, inner) if isinstance(member, list) else "%s.%s" % (key, inner)
|
|
121
|
+
what = "a string over 500 characters" if isinstance(leaf, str) else "a list" if isinstance(leaf, list) else \
|
|
122
|
+
"an object" if isinstance(leaf, dict) else "not a scalar"
|
|
123
|
+
return ": data.value.%s is %s; nest two levels at most (flatten it into lists or members of their own)" % (place, what)
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
114
127
|
def feed_data_problems(data: Any) -> List[str]:
|
|
115
128
|
"""DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
|
|
116
129
|
level, under a credential's name or of a credential's shape (named by its place, never its value)."""
|
|
@@ -130,7 +143,8 @@ def feed_data_problems(data: Any) -> List[str]:
|
|
|
130
143
|
continue
|
|
131
144
|
if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
|
|
132
145
|
continue
|
|
133
|
-
problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members"
|
|
146
|
+
problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members%s"
|
|
147
|
+
% (key, _too_deep(key, member)))
|
|
134
148
|
if problems:
|
|
135
149
|
return problems
|
|
136
150
|
for finding in feed_data_findings({"events": [{"data": data}]}):
|