@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.1

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 (127) hide show
  1. package/CHANGELOG.md +200 -3
  2. package/README.md +11 -6
  3. package/SECURITY.md +8 -5
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  111. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  112. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  113. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  114. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  115. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  116. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  117. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  118. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  124. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  125. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
  126. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
  127. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -93,26 +93,29 @@ class ProviderError(Exception):
93
93
  """An entry was refused; the message is one readable sentence and never quotes a value."""
94
94
 
95
95
 
96
- def services_dir() -> Path:
97
- override = os.environ.get("FABRIC_SERVICES_DIR")
98
- if override:
99
- return Path(override).expanduser()
96
+ def fabric_root() -> Path:
97
+ """The platform's Fabric root, as Fabric's registryDirs computes it: the folder `services/` and `providers/` sit in
98
+ when neither override is set."""
100
99
  home = Path.home()
101
100
  if sys.platform == "darwin":
102
- return home / "Library/Application Support/ai.passioncode.fabric/services"
101
+ return home / "Library/Application Support/ai.passioncode.fabric"
103
102
  if sys.platform == "win32":
104
103
  # provider.md "Where the directory is" (DEC-0032): LOCALAPPDATA, never the roaming APPDATA.
105
104
  local = os.environ.get("LOCALAPPDATA")
106
- return (Path(local) if local else home / "AppData" / "Local") / "passioncode-fabric" / "services"
107
- return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
105
+ return (Path(local) if local else home / "AppData" / "Local") / "passioncode-fabric"
106
+ return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
107
+
108
+
109
+ def services_dir() -> Path:
110
+ override = os.environ.get("FABRIC_SERVICES_DIR")
111
+ return Path(override).expanduser() if override else fabric_root() / "services"
108
112
 
109
113
 
110
114
  def providers_dir() -> Path:
111
- """FABRIC_PROVIDERS_DIR, else `providers/` in the same root as `services/`."""
115
+ """FABRIC_PROVIDERS_DIR, else `providers/` in the platform's Fabric root, as Fabric's registryDirs reads it: a set
116
+ FABRIC_SERVICES_DIR moves the services folder only (FAA-189 (2))."""
112
117
  override = os.environ.get("FABRIC_PROVIDERS_DIR")
113
- if override:
114
- return Path(override).expanduser()
115
- return services_dir().parent / "providers"
118
+ return Path(override).expanduser() if override else fabric_root() / "providers"
116
119
 
117
120
 
118
121
  def looks_like_credential(value: str) -> bool:
@@ -155,7 +158,7 @@ def validate_provider_entry(entry: Any) -> List[str]:
155
158
  # FAA-120, as the service kit: a repository is named, never reached with a credential in its URI. Said
156
159
  # before the character check, so a password beside a character the URI class refuses (a backslash, as
157
160
  # git reads one) is still named a credential (FAA-127).
158
- problems.append("source.repository carries no password or credential: it names the repository")
161
+ problems.append("source.repository carries no password or credential: it names the repository (write the repository's name in lower case if it is one)")
159
162
  elif "repository" in source and not _is_uri(source["repository"]):
160
163
  problems.append("source.repository is an absolute URI")
161
164
  if not isinstance(entry["installedAt"], str) or not _TIMESTAMP.fullmatch(entry["installedAt"]):
@@ -207,12 +210,17 @@ def _run_problems(run: Any) -> List[str]:
207
210
  return problems
208
211
 
209
212
 
213
+ def _no_constant(name: str) -> Any:
214
+ raise ValueError("%s is no JSON" % name)
215
+
216
+
210
217
  def manifest_problems(entry: Dict[str, Any]) -> List[str]:
211
218
  """FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId, compared by normal
212
219
  form (DEC-0076)."""
213
220
  try:
214
- manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"))
215
- except (OSError, ValueError):
221
+ manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"),
222
+ parse_constant=_no_constant)
223
+ except (OSError, ValueError, RecursionError): # NaN or Infinity is no JSON, as pack reads it (FAA-194 (38))
216
224
  return ["the manifest %s does not resolve (FAC-SEM-014)" % entry["manifest"]]
217
225
  provider = manifest.get("provider") if isinstance(manifest, dict) else None
218
226
  if not isinstance(provider, dict) or not isinstance(manifest.get("capabilities"), list):
@@ -300,6 +308,14 @@ def entry_stem(path: str) -> str:
300
308
  return re.sub(r"\.json\Z", "", re.sub(r"\A[^\n\r\u2028\u2029]*[\\/]", "", path, count=1))
301
309
 
302
310
 
311
+ def argument_stem(path: str, platform: Optional[str] = None) -> str:
312
+ """The stem of the file a `validate` argument names: CT-193's, after a Windows drive-relative prefix (`C:name.json`
313
+ is name.json in that drive's folder) is set aside, which the registry never meets (FAA-189 (8))."""
314
+ if (platform or sys.platform) == "win32":
315
+ path = re.sub(r"\A[A-Za-z]:(?![\\/])", "", path)
316
+ return entry_stem(path)
317
+
318
+
303
319
  def main(argv: Optional[List[str]] = None) -> int:
304
320
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
305
321
  sub = parser.add_subparsers(dest="command", required=True)
@@ -335,7 +351,7 @@ def main(argv: Optional[List[str]] = None) -> int:
335
351
  entry = json.loads(args.path.read_text(encoding="utf-8"))
336
352
  problems = validate_provider_entry(entry)
337
353
  # CT-193 (FAC-SEM-014): the stem is what follows the last / or \ and loses .json, on every platform.
338
- stem = entry_stem(str(args.path))
354
+ stem = argument_stem(str(args.path))
339
355
  if isinstance(entry, dict) and entry.get("id") != stem:
340
356
  problems.append("the file name %s is not <id>.json for %s (FAC-SEM-014)" % (args.path.name, entry.get("id")))
341
357
  if not problems:
@@ -10,8 +10,11 @@ U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import bisect
14
+ import ipaddress
13
15
  import re
14
- from typing import Dict, List, Tuple
16
+ from typing import Dict, List, Optional, Sequence, Tuple
17
+ import unicodedata
15
18
  import urllib.parse
16
19
 
17
20
  CREDENTIAL_WORDS = (
@@ -28,8 +31,36 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
28
31
  }
29
32
  #: ECMAScript's \s, as a character class body.
30
33
  JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
31
- _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
34
+ NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
32
35
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
36
+ _URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
37
+
38
+
39
+ def jwt_spans(text: str, first: bool = False) -> List[Tuple[int, int]]:
40
+ """The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's `jwtSpans`,
41
+ FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of
42
+ one run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends.
43
+ `first` stops at the first match."""
44
+ spans: List[Tuple[int, int]] = []
45
+ at = text.find("eyJ")
46
+ while at >= 0:
47
+ one = _URL_BASE64_RUN.match(text, at + 3).end()
48
+ resume = one
49
+ if one - at - 3 >= 2 and text[one:one + 1] == ".":
50
+ two = _URL_BASE64_RUN.match(text, one + 1).end()
51
+ if two - one - 1 >= 2 and text[two:two + 1] == ".":
52
+ three = _URL_BASE64_RUN.match(text, two + 1).end()
53
+ spans.append((at, three))
54
+ if first:
55
+ break
56
+ resume = three
57
+ at = text.find("eyJ", resume)
58
+ return spans
59
+
60
+
61
+ def has_jwt(text: str) -> bool:
62
+ """Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's `hasJwt`, FAA-313)."""
63
+ return bool(jwt_spans(text, True))
33
64
  #: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
34
65
  #: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
35
66
  #: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
@@ -43,14 +74,17 @@ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,25
43
74
  #: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
44
75
  #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
45
76
  #: read the matched text or its index.
46
- SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
77
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
47
78
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
48
- _BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
79
+ BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
49
80
  re.IGNORECASE | re.ASCII)
50
81
  _PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
51
82
  _WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
52
83
  _ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
53
- _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
84
+ SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
85
+ #: The names the check and older readers wrote before these became public (FAA-190 (2), (12)): kept so a module from
86
+ #: an earlier version beside this one still loads.
87
+ _BEARER_INSIDE, _SCHEME, _NOT_LINE_END = BEARER_INSIDE, SCHEME, NOT_LINE_END
54
88
  #: WHATWG's special schemes that carry an authority with user information (file has none).
55
89
  _SPECIAL = ("ftp", "http", "https", "ws", "wss")
56
90
 
@@ -77,13 +111,17 @@ def credential_name(name: str) -> bool:
77
111
  return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
78
112
 
79
113
 
80
- def _long_secret(value: str) -> bool:
114
+ def long_secret(value: str) -> bool:
115
+ """A run of 40 or more base64 characters mixing upper case, lower case and digits (the Node twin's longSecret)."""
81
116
  return any(re.search(r"[a-z]", run) and re.search(r"[A-Z]", run) and re.search(r"[0-9]", run) for run in _LONG_RUN.findall(value))
82
117
 
83
118
 
119
+ _long_secret = long_secret # its name before FAA-190 (2)
120
+
121
+
84
122
  def secret_value(value: str) -> bool:
85
123
  """A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
86
- return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _long_secret(value))
124
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret(value))
87
125
 
88
126
 
89
127
  def _path_like(piece: str) -> bool:
@@ -95,13 +133,13 @@ def _path_like(piece: str) -> bool:
95
133
  def secret_argument(value: str) -> bool:
96
134
  """The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
97
135
  so a folder path holding a session id is not taken for a key."""
98
- if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
136
+ if has_jwt(value) or SECRET_VALUE.search(value) or BEARER_INSIDE.search(value):
99
137
  return True
100
138
  for piece in _ARGUMENT_SPLIT.split(value):
101
139
  if _path_like(piece):
102
- if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
140
+ if any(long_secret(part) for part in re.split(r"[\\/]", piece)):
103
141
  return True
104
- elif _long_secret(piece):
142
+ elif long_secret(piece):
105
143
  return True
106
144
  return False
107
145
 
@@ -141,6 +179,155 @@ _C0_OR_SPACE = "".join(map(chr, range(0x21)))
141
179
  _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
142
180
 
143
181
 
182
+ # The WHATWG host parser, enough to know when `new URL` refuses a host (FAA-98, moved here from fabric_connections for
183
+ # secret_url in FAA-309, so the module still ships alone in adapting-projects-to-fabric).
184
+ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
185
+ _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
186
+
187
+
188
+ # FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
189
+ # the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
190
+ # test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
191
+ # code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
192
+ # classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
193
+ # Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
194
+ _IDNA_DISALLOWED = (
195
+ (0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
196
+ (0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
197
+ (0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
198
+ (0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
199
+ (0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
200
+ (0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
201
+ (0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
202
+ (0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
203
+ (0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
204
+ (0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
205
+ (0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
206
+ (0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
207
+ (0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
208
+ (0x100000, 0x10fffd),
209
+ )
210
+ _IDNA_IGNORED = (
211
+ (0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
212
+ (0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
213
+ )
214
+ _IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
215
+ _U32 = unicodedata.ucd_3_2_0
216
+ _RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
217
+ _JOINERS = "\u200c\u200d"
218
+
219
+
220
+ def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
221
+ i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
222
+ return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
223
+
224
+
225
+ def _known(c: str) -> bool:
226
+ return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
227
+
228
+
229
+ def _joiner_refused(label: str, i: int) -> bool:
230
+ """CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
231
+ (`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
232
+ in its label has none for certain."""
233
+ return all(c.isascii() for j, c in enumerate(label) if j != i)
234
+
235
+
236
+ def _bidi_refused(label: str) -> bool:
237
+ """RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
238
+ not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
239
+ if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
240
+ return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
241
+ k = [_U32.bidirectional(c) for c in label]
242
+ if not any(x in ("R", "AL", "AN") for x in k):
243
+ return False
244
+ body = list(k)
245
+ while body and body[-1] == "NSM":
246
+ body.pop()
247
+ if not body or body[-1] not in ("R", "AL", "EN", "AN"):
248
+ return True
249
+ return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
250
+
251
+
252
+ def _idna_host(host: str) -> Optional[str]:
253
+ """None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
254
+ as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
255
+ if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
256
+ return None
257
+ kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
258
+ if kept == "":
259
+ return None # nothing left: an empty host
260
+ if not all(_known(c) for c in kept):
261
+ return host # a code point this Python's Unicode does not know: the contextual rules are not read
262
+ mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
263
+ for label in _IDNA_DOTS.split(mapped):
264
+ first = label[:1]
265
+ if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
266
+ return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
267
+ if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
268
+ return None
269
+ if _bidi_refused(label):
270
+ return None
271
+ return _IDNA_DOTS.sub(".", mapped)
272
+
273
+
274
+ def _ipv4_number(part: str) -> Optional[int]:
275
+ if part == "":
276
+ return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
277
+ base = 10
278
+ if part[:2].lower() == "0x":
279
+ part, base = part[2:], 16
280
+ elif len(part) > 1 and part[0] == "0":
281
+ part, base = part[1:], 8
282
+ if part == "":
283
+ return 0
284
+ digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
285
+ return int(part, base) if all(c in digits for c in part) else None
286
+
287
+
288
+ def _domain_ok(host: str) -> bool:
289
+ """The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
290
+ if host.startswith("["):
291
+ return host.endswith("]") and _ipv6_ok(host[1:-1])
292
+ if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
293
+ try:
294
+ host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
295
+ except UnicodeDecodeError:
296
+ return False
297
+ if any(c in _FORBIDDEN_DOMAIN for c in host):
298
+ return False
299
+ # A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
300
+ # the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
301
+ # (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
302
+ if any(ord(c) > 0x7f for c in host):
303
+ mapped = _idna_host(host)
304
+ if mapped is None:
305
+ return False
306
+ if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
307
+ return True
308
+ host = mapped
309
+ labels = host.split(".")
310
+ if labels and labels[-1] == "" and len(labels) > 1:
311
+ labels = labels[:-1]
312
+ last = labels[-1] if labels else ""
313
+ if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
314
+ return host != ""
315
+ numbers = [_ipv4_number(label) for label in labels]
316
+ if len(numbers) > 4 or any(n is None for n in numbers):
317
+ return False
318
+ return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
319
+
320
+
321
+ def _ipv6_ok(text: str) -> bool:
322
+ if "%" in text:
323
+ return False # a zone id: `ipaddress` takes one, WHATWG does not
324
+ try:
325
+ ipaddress.IPv6Address(text)
326
+ return True
327
+ except ValueError:
328
+ return False
329
+
330
+
144
331
  def _percent_encode(text: str, also: str) -> str:
145
332
  """WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
146
333
  above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
@@ -162,8 +349,8 @@ def url_parts(value: str) -> Tuple[str, str, str]:
162
349
  after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
163
350
  next slash, backslash, `?` or `#`, so `<https>:u:p@h` and `<https>:\\u:p@h` carry the password p; any other scheme
164
351
  has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
165
- last `@`, and the password follows its first `:`. A host `new URL` would refuse is not checked: such a URL is read
166
- for its password and parameters all the same, the safe direction for a detector."""
352
+ last `@`, and the password follows its first `:`. The host is not checked here: secret_url refuses first a URL
353
+ whose host or port `new URL` refuses (url_refused, FAA-309)."""
167
354
  scheme, _, rest = value.partition(":")
168
355
  rest, _, fragment = rest.partition("#")
169
356
  rest, _, query = rest.partition("?")
@@ -182,17 +369,69 @@ def url_parts(value: str) -> Tuple[str, str, str]:
182
369
  _url_parts = url_parts # the name before FAA-250, kept for code that reached it
183
370
 
184
371
 
372
+ def url_refused(value: str) -> bool:
373
+ """Whether `new URL` refuses an absolute URL (trimmed as secret_url trims it) for its host or port (FAA-309), read
374
+ with the same authority url_parts reads: a special scheme's host through the WHATWG host parser and the IDNA reading
375
+ of FAA-98 (`_domain_ok`), never empty; `file`'s host, after `//`, the same, empty allowed; another scheme's host,
376
+ after `//`, without a forbidden host code point, and empty only with no user information and no `:` after it (an
377
+ empty port is refused too); a port of ASCII digits up to 65535. Where the reading is unsure, the host is taken as
378
+ valid (the detector's safe side)."""
379
+ value = _LONE_SURROGATE.sub("\ufffd", value)
380
+ scheme, _, rest = value.partition(":")
381
+ rest = rest.partition("#")[0].partition("?")[0]
382
+ lowered = scheme.lower()
383
+ if lowered in _SPECIAL:
384
+ authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
385
+ elif lowered == "file":
386
+ rest = rest.replace("\\", "/")
387
+ if not rest.startswith("//"):
388
+ return False
389
+ host = rest[2:].partition("/")[0]
390
+ return not (host == "" or re.fullmatch(r"[A-Za-z][:|]", host) or _domain_ok(host))
391
+ elif rest.startswith("//"):
392
+ authority = rest[2:].partition("/")[0]
393
+ else:
394
+ return False
395
+ hostport = authority.rpartition("@")[2]
396
+ if hostport.startswith("["):
397
+ close = hostport.find("]")
398
+ if close < 0:
399
+ return True
400
+ host, after = hostport[:close + 1], hostport[close + 1:]
401
+ if after and not after.startswith(":"):
402
+ return True
403
+ port = after[1:]
404
+ else:
405
+ host, _, port = hostport.partition(":")
406
+ if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
407
+ return True
408
+ if lowered in _SPECIAL:
409
+ return host == "" or not _domain_ok(host)
410
+ if host.startswith("["):
411
+ return not _ipv6_ok(host[1:-1])
412
+ return any(c in _FORBIDDEN_HOST for c in host) or (host == "" and ("@" in authority or ":" in hostport))
413
+
414
+
185
415
  def secret_url(value: str) -> bool:
186
416
  """Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
187
417
  password in its user information, or a query or fragment parameter whose name can only carry a credential and has
188
- a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
418
+ a value, or whose value is shaped like one. A string that is not an absolute URL, or one whose host or port
419
+ `new URL` refuses (url_refused, FAA-309), is checked as an argument only, as the Node twin's secretUrl reads it."""
420
+ return _url_credential(value, hosts=True)
421
+
422
+
423
+ def _url_credential(value: str, hosts: bool) -> bool:
424
+ """secret_url; with `hosts` False the host is not checked, as repository_credential reads a repository's parameters
425
+ whatever its host (the Node twin's repositoryParts)."""
189
426
  if secret_argument(value):
190
427
  return True
191
428
  # What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
192
429
  # refuses), then remove every tab, CR and LF.
193
430
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
194
- if not _SCHEME.match(value):
431
+ if not SCHEME.match(value):
195
432
  return False # not an absolute URL (WHATWG URL parsing refuses it)
433
+ if hosts and url_refused(value):
434
+ return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
196
435
  password, query, fragment = url_parts(value)
197
436
  if password:
198
437
  return True
@@ -210,7 +449,25 @@ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
210
449
  #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
211
450
  _REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
212
451
  _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
213
- _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
452
+
453
+
454
+ def jwt_at_a_word(text: str) -> bool:
455
+ """Whether JWT_SHAPE matches at a word's start: the text's start, or after a `.`, `_` or `-` (FAA-321). Read in one
456
+ pass, as jwt_spans: the look-behind expression backtracked from every such `eyJ` of a long run, quadratic in it.
457
+ Every `eyJ` of one run shares that run's end and a shorter first segment, so only a run's first `eyJ` at a word's
458
+ start is tried; an `eyJ` that is not at one is passed over, since a later one of its run may be."""
459
+ at = text.find("eyJ")
460
+ while at >= 0:
461
+ if at and text[at - 1] not in "._-":
462
+ at = text.find("eyJ", at + 1)
463
+ continue
464
+ one = _URL_BASE64_RUN.match(text, at + 3).end()
465
+ if one - at - 3 >= 2 and text[one:one + 1] == ".":
466
+ two = _URL_BASE64_RUN.match(text, one + 1).end()
467
+ if two - one - 1 >= 2 and text[two:two + 1] == ".":
468
+ return True
469
+ at = text.find("eyJ", one)
470
+ return False
214
471
 
215
472
 
216
473
  def repository_name(piece: str) -> bool:
@@ -218,7 +475,7 @@ def repository_name(piece: str) -> bool:
218
475
  key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
219
476
  readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
220
477
  inside a word, is a name."""
221
- return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not _JWT_AT_A_WORD.search(piece)
478
+ return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not jwt_at_a_word(piece)
222
479
 
223
480
 
224
481
  def ascii_decoded(text: str) -> str:
@@ -260,4 +517,4 @@ def repository_credential(value: str) -> bool:
260
517
  odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
261
518
  if piece and not repository_name(piece)]
262
519
  # the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
263
- return secret_url(head + tail) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
520
+ return _url_credential(head + tail, hosts=False) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)