@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
  46. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
  50. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
  51. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
  52. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
  53. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
  56. 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 CREDENTIAL_PAIRS, CREDENTIAL_WORDS, JWT_SHAPE, _long_secret, raw_values # noqa: E402
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: ASCII case folding, ECMAScript's \s and `.`,
165
- # an ASCII word boundary. The kit's fabric_secrets keeps Python's semantics for its other callers (LC-21); FAC-SEM-127
166
- # is held to the reference, so this module reads with the reference's (blind reading of 0.44.0, finding 1).
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[1:] if raw_name.startswith("?") else 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
- return ipaddress.ip_address(host).is_loopback
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
- request = urllib.request.Request(source, headers={"Accept": "application/json"})
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
- parts = urllib.parse.urlsplit(source)
706
- if parts.username or parts.password:
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
- _URI_PASSWORD = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?#@]*:[^/?#@]*@")
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, **extra: Any) -> Dict[str, Any]:
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)
@@ -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
- try:
120
- parts = urllib.parse.urlsplit(str(url))
121
- except ValueError:
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
- host = parts.hostname or ""
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
- try:
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 ("/" if parts.scheme.lower() in ("http", "https") else "")
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
- parts = urllib.parse.urlsplit(url)
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
- req = urllib.request.Request(url, data=body, method=method, headers=headers)
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 tool."""
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
- step = job_step(self.meta.get("mandate"), tier=self.tier, now=format_rfc3339(self.server._clock() * 1000),
521
- self_id=self.server.agent_id, capability=self.tool, key=key, effect=effect, caller_agent=caller,
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(self.tool, step) # the start hears it as the answer does (MJ-1)
526
- kept = {"effect": effect, **({"spendUsd": spend_usd} if spend_usd is not None else {}),
527
- **({"project": project} if project is not None else {})}
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 = "\x00unreadable" # never a member of a mandate's projects, so a scoped mandate refuses
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._fault(name, inputs)
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
- if isinstance(spend_usd, (int, float)) and not isinstance(spend_usd, bool):
346
- call["spendUsd"] = spend_usd
347
- if isinstance(project, str):
348
- call["project"] = project
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). A covered call
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
- self.base_url = base_url.rstrip("/")
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" % key)
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}]}):