@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.84.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 (165) hide show
  1. package/CHANGELOG.md +169 -3
  2. package/README.md +7 -3
  3. package/SECURITY.md +4 -3
  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/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +1 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +38 -12
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +4 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +2 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +3 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +28 -12
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +13 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +11 -7
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +275 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +1 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +5 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +1 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +15 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +55 -11
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +47 -31
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +1 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +25 -7
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +27 -5
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +1 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +1 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +4 -2
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +19 -12
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +1 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -1
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +22 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +4 -5
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +7 -2
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +1 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +21 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +6 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +1 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +1 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +16 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +1 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +6 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +25 -3
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +456 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +1 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +18 -2
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +1 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +19 -5
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +56 -19
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -0
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +16 -2
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +181 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +20 -15
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +469 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +1 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +1 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +1 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +3 -1
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +405 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +1 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +1 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +17 -3
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +98 -9
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +37 -48
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +1 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +25 -8
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +1 -0
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +28 -6
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +1 -0
  108. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +1 -0
  109. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +19 -13
  110. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +24 -14
  111. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -0
  112. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +1 -0
  113. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -1
  114. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +32 -7
  115. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +19 -16
  116. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +10 -5
  117. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +1 -0
  118. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +56 -96
  119. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +99 -1
  120. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +1 -0
  121. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +1 -0
  122. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +16 -6
  123. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +1 -0
  124. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +1 -0
  125. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +44 -1
  126. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +648 -0
  127. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +17 -4
  128. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +1 -0
  129. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +1 -0
  130. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -5
  131. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +108 -108
  132. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -0
  133. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -0
  134. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -0
  135. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +38 -12
  136. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  137. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +1 -0
  138. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +391 -0
  139. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +19 -16
  140. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +1 -0
  141. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +493 -0
  142. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +1 -0
  143. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -0
  144. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +632 -104
  145. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  146. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +17 -7
  147. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  148. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -5
  149. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  150. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  151. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  152. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  153. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  154. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -3
  155. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +182 -0
  156. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  157. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  158. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  159. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  160. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  161. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  162. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  163. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  164. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  165. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
3
  """The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
3
4
  that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
4
5
  (LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
@@ -31,7 +32,7 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
31
32
  }
32
33
  #: ECMAScript's \s, as a character class body.
33
34
  JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
34
- _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
35
+ NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
35
36
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
36
37
  _URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
37
38
 
@@ -74,14 +75,17 @@ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,25
74
75
  #: 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
75
76
  #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
76
77
  #: read the matched text or its index.
77
- SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
78
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
78
79
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
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,
80
+ BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
80
81
  re.IGNORECASE | re.ASCII)
81
82
  _PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
82
83
  _WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
83
84
  _ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
84
- _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
85
+ SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
86
+ #: The names the check and older readers wrote before these became public (FAA-190 (2), (12)): kept so a module from
87
+ #: an earlier version beside this one still loads.
88
+ _BEARER_INSIDE, _SCHEME, _NOT_LINE_END = BEARER_INSIDE, SCHEME, NOT_LINE_END
85
89
  #: WHATWG's special schemes that carry an authority with user information (file has none).
86
90
  _SPECIAL = ("ftp", "http", "https", "ws", "wss")
87
91
 
@@ -108,13 +112,17 @@ def credential_name(name: str) -> bool:
108
112
  return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
109
113
 
110
114
 
111
- def _long_secret(value: str) -> bool:
115
+ def long_secret(value: str) -> bool:
116
+ """A run of 40 or more base64 characters mixing upper case, lower case and digits (the Node twin's longSecret)."""
112
117
  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))
113
118
 
114
119
 
120
+ _long_secret = long_secret # its name before FAA-190 (2)
121
+
122
+
115
123
  def secret_value(value: str) -> bool:
116
124
  """A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
117
- return bool(has_jwt(value) or SECRET_VALUE.search(value) or _long_secret(value))
125
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret(value))
118
126
 
119
127
 
120
128
  def _path_like(piece: str) -> bool:
@@ -126,13 +134,13 @@ def _path_like(piece: str) -> bool:
126
134
  def secret_argument(value: str) -> bool:
127
135
  """The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
128
136
  so a folder path holding a session id is not taken for a key."""
129
- if has_jwt(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
137
+ if has_jwt(value) or SECRET_VALUE.search(value) or BEARER_INSIDE.search(value):
130
138
  return True
131
139
  for piece in _ARGUMENT_SPLIT.split(value):
132
140
  if _path_like(piece):
133
- if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
141
+ if any(long_secret(part) for part in re.split(r"[\\/]", piece)):
134
142
  return True
135
- elif _long_secret(piece):
143
+ elif long_secret(piece):
136
144
  return True
137
145
  return False
138
146
 
@@ -421,7 +429,7 @@ def _url_credential(value: str, hosts: bool) -> bool:
421
429
  # What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
422
430
  # refuses), then remove every tab, CR and LF.
423
431
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
424
- if not _SCHEME.match(value):
432
+ if not SCHEME.match(value):
425
433
  return False # not an absolute URL (WHATWG URL parsing refuses it)
426
434
  if hosts and url_refused(value):
427
435
  return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
@@ -442,7 +450,25 @@ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
442
450
  #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
443
451
  _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,}"
444
452
  _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
445
- _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
453
+
454
+
455
+ def jwt_at_a_word(text: str) -> bool:
456
+ """Whether JWT_SHAPE matches at a word's start: the text's start, or after a `.`, `_` or `-` (FAA-321). Read in one
457
+ pass, as jwt_spans: the look-behind expression backtracked from every such `eyJ` of a long run, quadratic in it.
458
+ 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
459
+ start is tried; an `eyJ` that is not at one is passed over, since a later one of its run may be."""
460
+ at = text.find("eyJ")
461
+ while at >= 0:
462
+ if at and text[at - 1] not in "._-":
463
+ at = text.find("eyJ", at + 1)
464
+ continue
465
+ one = _URL_BASE64_RUN.match(text, at + 3).end()
466
+ if one - at - 3 >= 2 and text[one:one + 1] == ".":
467
+ two = _URL_BASE64_RUN.match(text, one + 1).end()
468
+ if two - one - 1 >= 2 and text[two:two + 1] == ".":
469
+ return True
470
+ at = text.find("eyJ", one)
471
+ return False
446
472
 
447
473
 
448
474
  def repository_name(piece: str) -> bool:
@@ -450,7 +476,7 @@ def repository_name(piece: str) -> bool:
450
476
  key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
451
477
  readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
452
478
  inside a word, is a name."""
453
- return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not _JWT_AT_A_WORD.search(piece)
479
+ return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not jwt_at_a_word(piece)
454
480
 
455
481
 
456
482
  def ascii_decoded(text: str) -> str:
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Reference kit for the fabric-service/0.1 local service extension: the facade.
2
3
 
3
4
  Since 0.40.0 (FAA-65) the kit is a set of modules, and this file re-exports every name it defined at 0.39.1, so
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
3
  """Share grants into an organization and their registrations (contract DEC-0086), the Python kit; the Node twin is
3
4
  fabric-share.mjs. Standard library only.
4
5
 
@@ -0,0 +1,391 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
+ """fabric_socket_proof: this OS account's own TCP sockets, read from its own processes (layer L0; DEC-0155 RT-2,
3
+ FAA-292; FAA-272).
4
+
5
+ macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew or uv CPython) only its own sockets in the host's socket
6
+ table, so `fabric_peer_owner` reads such a table as `table-restricted` and cannot tell who accepted a connection. The
7
+ question can still be asked of this account's own processes, which the restriction leaves readable: whether one of them
8
+ holds the very socket that accepted the connection (RT-2, the accepted-socket proof), or a listener bound exactly to
9
+ 127.0.0.1:<port> (HA-2's macOS row, FAA-272). Another account's process answers EPERM and is skipped, so a proof only
10
+ ever says "this account's" or "not proved"; never another account's, the system's or nobody's.
11
+
12
+ accepted(server, client, wait=...) -> Proof the socket at `server` connected to `client` is in one of this
13
+ account's processes (`wait`: read again for ACCEPT_WAIT_SECONDS
14
+ until the server has called accept())
15
+ own_listener(port) -> Proof a listener of this account on exactly 127.0.0.1:<port>
16
+
17
+ Two readers, chosen by `method()`:
18
+
19
+ - `libproc` (Python's): `proc_listpids(PROC_UID_ONLY)`, `proc_pidinfo(PROC_PIDLISTFDS)` and
20
+ `proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` through ctypes, the records `lsof` itself reads, without a subprocess:
21
+ Project Observatory's `peer_owner` 1.3.1 reader (sha256 `b3831e5f...`, FAA-292). The `socket_fdinfo` layout is not
22
+ an API, so the first read in a process calibrates it on a connected pair of its own, as the sysctl reader does; a
23
+ layout that fails is never used again in this process, and a calibration that could not read at all is asked again.
24
+ - `lsof` (the Node twin's, and Python's when libproc is unavailable or uncalibrated): `lsof -nP -a -u <uid>
25
+ -iTCP:<port> -sTCP:<state> -F pun`, the exact address pair and the numeric uid. The twins agree on answers, not on
26
+ the program they run (DEC-0155 Compatibility); `test/fixtures/peer-owner/socket-proof-cases.json` holds both.
27
+
28
+ The proof is weaker than a table's `so_uid`: "a process of this uid holds the descriptor", not "the socket's
29
+ credential is this uid". A socket another account accepted and passed with SCM_RIGHTS to a process of this uid would
30
+ read as this account's; a caller that knows the server's pid closes that with `expect_pid`. One socket several of this
31
+ account's processes hold (an inherited descriptor) is proved with pid None, reason `several-processes`. A process whose
32
+ real and effective uids differ is refused (`setuid-context`): XNU lists and checks processes by the effective uid.
33
+ Reasons, as DEC-0155 RT-1 spells them: `table-restricted` (read, and no socket of this account matches) or
34
+ `table-restricted: <detail>` (the proof could not be read). Bounded by MAX_PIDS, MAX_FDS and TIMEOUT_SECONDS; nothing
35
+ is cached across questions but the calibration verdict. Standard library only, no other kit module. Tests:
36
+ `test/test_fabric_socket_proof.py`; the Node twin is `fabric-socket-proof.mjs`.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import ipaddress
41
+ import os
42
+ import socket
43
+ import struct
44
+ import subprocess
45
+ import sys
46
+ import threading
47
+ import time
48
+ from typing import Callable, List, NamedTuple, Optional, Tuple
49
+
50
+ TABLE_RESTRICTED = "table-restricted"
51
+ LIBPROC = "libproc"
52
+ LSOF_METHOD = "lsof"
53
+ LSOF = "/usr/sbin/lsof"
54
+ #: Every read of one question is over within this many seconds (the accept wait included).
55
+ TIMEOUT_SECONDS = 2.0
56
+ #: How long `accepted(..., wait=True)` reads again for a server that has not called accept() yet.
57
+ ACCEPT_WAIT_SECONDS = 0.5
58
+ MAX_PIDS = 16384
59
+ MAX_FDS = 65536
60
+ MAX_OUTPUT_BYTES = 4 * 1024 * 1024
61
+
62
+ #: macOS `sys/proc_info.h`: selectors and the `struct socket_fdinfo` offsets read here. The struct is 792 bytes:
63
+ #: `proc_fileinfo` (24), then `socket_info`, whose `soi_so` is the kernel's socket (the same in every process that
64
+ #: holds it) and whose `soi_proto.pri_tcp` starts with an `in_sockinfo` (ports as ints holding a network-order port,
65
+ #: `insi_vflag`, the foreign and local `in4in6_addr`) and carries `tcpsi_state`.
66
+ PROC_UID_ONLY = 4
67
+ PROC_PIDLISTFDS = 1
68
+ PROX_FDTYPE_SOCKET = 2
69
+ PROC_PIDFDSOCKETINFO = 3
70
+ SOCKINFO_TCP = 2
71
+ SOCKET_FDINFO_SIZE = 792
72
+ _SOI = 24 # socket_fdinfo.psi
73
+ _SOI_SO = _SOI + 136 # socket_info.soi_so
74
+ _SOI_KIND = _SOI + 232 # socket_info.soi_kind
75
+ _INI = _SOI + 240 # soi_proto.pri_tcp.tcpsi_ini
76
+ _INI_VFLAG = _INI + 24 # INI_IPV4 0x1, INI_IPV6 0x2
77
+ _INI_FOREIGN = _INI + 32
78
+ _INI_LOCAL = _INI + 48
79
+ _TCPSI_STATE = _INI + 80 # TSI_S_CLOSED 0, TSI_S_LISTEN 1, TSI_S_TIME_WAIT 10
80
+ _TSI_S_CLOSED = 0
81
+ _TSI_S_LISTEN = 1
82
+ _TSI_S_TIME_WAIT = 10
83
+
84
+ Address = Tuple[str, int]
85
+ # Real types, not postponed strings (as fabric_peer_owner's Owner): a NamedTuple's generated __new__ resolves its
86
+ # annotations in a namespace of its own, where `Address` is no name.
87
+ _SocketFields = NamedTuple("_SocketFields", [("local", Tuple[str, int]), ("remote", Tuple[str, int]),
88
+ ("listening", bool), ("pid", Optional[int])])
89
+ _ProofFields = NamedTuple("_ProofFields", [("proved", bool), ("pid", Optional[int]), ("method", str),
90
+ ("reason", Optional[str])])
91
+
92
+
93
+ class Socket(_SocketFields):
94
+ """One TCP socket of this account: `pid` is None when several of its processes hold it."""
95
+ __slots__ = ()
96
+
97
+
98
+ class Proof(_ProofFields):
99
+ """Whether this account's process holds the socket asked about; `reason` says why not, or `several-processes`."""
100
+ __slots__ = ()
101
+
102
+
103
+ class ProofUnreadable(Exception):
104
+ """This account's sockets could not be read; the message is the reason, never a socket's content."""
105
+
106
+
107
+ def _host(host: str) -> str:
108
+ """One spelling per address, as fabric_peer_owner.norm_host (the same rule, kept here so this module stands alone):
109
+ an IPv4-mapped IPv6 address is its IPv4 form, an unspecified one `*`, `localhost` 127.0.0.1, and `[...]` loses its
110
+ brackets. Anything unparseable is returned unchanged (and matches only itself)."""
111
+ if host.startswith("[") and host.endswith("]"):
112
+ host = host[1:-1]
113
+ if host in ("*", ""):
114
+ return "*"
115
+ if host == "localhost":
116
+ return "127.0.0.1"
117
+ host = host.split("%", 1)[0]
118
+ try:
119
+ ip = ipaddress.ip_address(host)
120
+ except ValueError:
121
+ return host
122
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
123
+ ip = ip.ipv4_mapped
124
+ return "*" if ip.is_unspecified else str(ip)
125
+
126
+
127
+ def _digits(text: str) -> bool:
128
+ """`[0-9]+`, as the Node twin reads a number (`str.isdigit` also takes `٤`)."""
129
+ return text != "" and text.isascii() and text.isdigit()
130
+
131
+
132
+ def loopback_host(host: str) -> bool:
133
+ """Whether a URL's host, as a URL parser leaves it (urlsplit's `hostname`, lower case, no brackets), names this
134
+ computer's loopback the way a WHATWG parser reads it: `localhost` or a name under `.localhost` (which resolves to
135
+ loopback), ::1, or an IPv4 address in 127/8 in any form `new URL` reads (`127.1`, `2130706433`, `0x7f.1`, one
136
+ trailing dot). Never an IPv4-mapped address (Node writes it in hex and refuses it), one with a zone, or whitespace.
137
+ The one rule fabric_http's check_url and fabric_delegation's key set source read (FAA-187 (15)); the Node twin's
138
+ `loopbackHost` reads `new URL`'s hostname."""
139
+ host = (host or "").lower()
140
+ if host == "localhost" or host.endswith(".localhost"):
141
+ return True
142
+ if not host or any(c.isspace() for c in host) or "%" in host:
143
+ return False
144
+ try:
145
+ address = ipaddress.ip_address(host)
146
+ return address.is_loopback and not getattr(address, "ipv4_mapped", None)
147
+ except ValueError:
148
+ pass
149
+ host = host[:-1] if host.endswith(".") else host
150
+ try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
151
+ return socket.inet_aton(host)[0] == 127
152
+ except (OSError, ValueError):
153
+ return False
154
+
155
+
156
+ # -- parsers: pure functions over captured records and text (test/fixtures/peer-owner/socket-proof-cases.json) -------
157
+
158
+ def parse_socket_fdinfo(blob: bytes) -> Optional[Tuple[Address, Address, int, int]]:
159
+ """One `proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` record: (local, remote, TCP state, socket handle) for a TCP socket,
160
+ None for any other kind. A short record is unreadable."""
161
+ if len(blob) < SOCKET_FDINFO_SIZE:
162
+ raise ProofUnreadable("libproc: short socket record")
163
+ if struct.unpack_from("<i", blob, _SOI_KIND)[0] != SOCKINFO_TCP:
164
+ return None
165
+ fport, lport = struct.unpack_from(">H", blob, _INI)[0], struct.unpack_from(">H", blob, _INI + 4)[0]
166
+ family, at = (socket.AF_INET, 12) if blob[_INI_VFLAG] & 0x1 else (socket.AF_INET6, 0)
167
+ size = 4 if family == socket.AF_INET else 16
168
+ foreign = socket.inet_ntop(family, bytes(blob[_INI_FOREIGN + at:_INI_FOREIGN + at + size]))
169
+ local = socket.inet_ntop(family, bytes(blob[_INI_LOCAL + at:_INI_LOCAL + at + size]))
170
+ state = struct.unpack_from("<i", blob, _TCPSI_STATE)[0]
171
+ return (_host(local), lport), (_host(foreign), fport), state, struct.unpack_from("<Q", blob, _SOI_SO)[0]
172
+
173
+
174
+ def own_sockets(records: List[Tuple[int, Address, Address, int, int]]) -> List[Socket]:
175
+ """Sockets from this account's records (pid, local, remote, state, handle): one per handle, pid None when several
176
+ processes hold it. Closed and TIME_WAIT sockets are left out."""
177
+ by_handle: dict = {}
178
+ for pid, local, remote, state, handle in records:
179
+ if state in (_TSI_S_CLOSED, _TSI_S_TIME_WAIT):
180
+ continue
181
+ by_handle.setdefault(handle, (local, remote, state, []))[3].append(pid)
182
+ return [Socket(local, remote, state == _TSI_S_LISTEN, pids[0] if len(set(pids)) == 1 else None)
183
+ for local, remote, state, pids in by_handle.values()]
184
+
185
+
186
+ def _lsof_address(text: str) -> Optional[Address]:
187
+ """`127.0.0.1:47311`, `[::1]:47070` or `*:47312`: the port after the last colon, ASCII digits only."""
188
+ host, sep, port = text.rpartition(":")
189
+ if not sep or not _digits(port) or int(port) > 65535:
190
+ return None
191
+ return _host(host), int(port)
192
+
193
+
194
+ def parse_lsof_sockets(text: str, uid: int) -> List[Socket]:
195
+ """`lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`: the `n` lines of every process block whose `u` line is
196
+ exactly `uid` (a block of another uid, or with no uid, is not read). A name with `->` is a connection, one without a
197
+ listener. The same socket listed by several processes (one address pair: TCP holds one connection per pair) is one
198
+ socket with pid None."""
199
+ found: dict = {}
200
+ pid = owner = None
201
+ for line in text.splitlines():
202
+ tag, value = line[:1], line[1:]
203
+ if tag == "p":
204
+ pid, owner = (int(value) if _digits(value) else None), None
205
+ elif tag == "u":
206
+ owner = int(value) if _digits(value) else None
207
+ elif tag == "n" and pid is not None and owner == uid:
208
+ local_text, arrow, remote_text = value.partition("->")
209
+ local = _lsof_address(local_text)
210
+ remote = _lsof_address(remote_text) if arrow else ("*", 0)
211
+ if local is None or remote is None:
212
+ continue
213
+ key = (local, remote, not arrow)
214
+ found.setdefault(key, []).append(pid)
215
+ return [Socket(local, remote, listening, pids[0] if len(set(pids)) == 1 else None)
216
+ for (local, remote, listening), pids in found.items()]
217
+
218
+
219
+ def _proved(hits: List[Socket], how: str) -> Proof:
220
+ if not hits:
221
+ return Proof(False, None, how, TABLE_RESTRICTED)
222
+ pids = {s.pid for s in hits}
223
+ if len(pids) == 1 and None not in pids:
224
+ return Proof(True, pids.pop(), how, None)
225
+ return Proof(True, None, how, "several-processes")
226
+
227
+
228
+ def accepted_in(sockets: List[Socket], server: Address, client: Address, how: str) -> Proof:
229
+ """RT-2 over this account's sockets: the one at `server` connected to `client`. Two different sockets with that
230
+ address pair cannot be told apart (`several-rows`)."""
231
+ hits = [s for s in sockets if not s.listening and s.local == server and s.remote == client]
232
+ if len(hits) > 1:
233
+ return Proof(False, None, how, "several-rows")
234
+ return _proved(hits, how)
235
+
236
+
237
+ def listener_in(sockets: List[Socket], port: int, how: str) -> Proof:
238
+ """FAA-272 over this account's sockets: a listener bound exactly to 127.0.0.1:<port>. XNU refuses another ordinary
239
+ account that bind beside ours (`in_pcbbind`); our wildcard or ::1 listener, and root's, cannot be told from a
240
+ hidden one, so they prove nothing (DEC-0155 RT-4)."""
241
+ return _proved([s for s in sockets if s.listening and s.local == ("127.0.0.1", port)], how)
242
+
243
+
244
+ # -- readers ----------------------------------------------------------------------------------------------------------
245
+
246
+ _LOCK = threading.Lock()
247
+ #: None until a calibration of the libproc layout reaches a verdict in this process, then True or False.
248
+ _LIBPROC_OK: list = [None]
249
+
250
+
251
+ def _run(argv: List[str]) -> str:
252
+ """One bounded run of lsof; its exit 1 with nothing printed is "no socket matched", not a failure."""
253
+ try:
254
+ done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT_SECONDS, check=False, stdin=subprocess.DEVNULL)
255
+ except (OSError, subprocess.TimeoutExpired) as exc:
256
+ raise ProofUnreadable("lsof: %s" % type(exc).__name__) from None
257
+ if done.returncode == 1 and not done.stdout.strip():
258
+ return ""
259
+ if done.returncode != 0:
260
+ raise ProofUnreadable("lsof: exit %d" % done.returncode)
261
+ if len(done.stdout) > MAX_OUTPUT_BYTES:
262
+ raise ProofUnreadable("lsof: output over %d bytes" % MAX_OUTPUT_BYTES)
263
+ return done.stdout.decode("utf-8", "replace")
264
+
265
+
266
+ def _libproc_records(uid: int, deadline: float) -> List[Tuple[int, Address, Address, int, int]]:
267
+ """This uid's processes' TCP sockets, as (pid, local, remote, state, handle). A process that cannot be read (gone,
268
+ or EPERM) is skipped: it can only lose an answer, never make one."""
269
+ try:
270
+ import ctypes
271
+ lib = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
272
+ lib.proc_listpids.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int]
273
+ lib.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int]
274
+ lib.proc_pidfdinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int]
275
+ except (ImportError, OSError, TypeError, AttributeError) as exc:
276
+ raise ProofUnreadable("libproc: %s" % type(exc).__name__) from None
277
+ for fn in (lib.proc_listpids, lib.proc_pidinfo, lib.proc_pidfdinfo):
278
+ fn.restype = ctypes.c_int
279
+ need = lib.proc_listpids(PROC_UID_ONLY, uid, None, 0)
280
+ if need <= 0:
281
+ raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
282
+ pids = (ctypes.c_int * min(need // 4 + 64, MAX_PIDS))()
283
+ got = lib.proc_listpids(PROC_UID_ONLY, uid, pids, ctypes.sizeof(pids))
284
+ if got <= 0:
285
+ raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
286
+ if got // 4 >= MAX_PIDS:
287
+ raise ProofUnreadable("libproc: over %d processes" % MAX_PIDS)
288
+ record = ctypes.create_string_buffer(SOCKET_FDINFO_SIZE)
289
+ out = []
290
+ for pid in pids[:got // 4]:
291
+ if pid <= 0:
292
+ continue
293
+ if time.monotonic() > deadline:
294
+ raise ProofUnreadable("libproc: deadline")
295
+ size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, None, 0)
296
+ if size <= 0:
297
+ continue
298
+ fds = ctypes.create_string_buffer(min(size + 32 * 8, MAX_FDS * 8))
299
+ size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, len(fds))
300
+ for i in range(max(size, 0) // 8):
301
+ fd, kind = struct.unpack_from("<iI", fds, i * 8)
302
+ if kind != PROX_FDTYPE_SOCKET:
303
+ continue
304
+ if lib.proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, record, SOCKET_FDINFO_SIZE) != SOCKET_FDINFO_SIZE:
305
+ continue
306
+ parsed = parse_socket_fdinfo(record.raw)
307
+ if parsed is not None:
308
+ out.append((pid,) + parsed)
309
+ return out
310
+
311
+
312
+ def _calibrate_libproc() -> Optional[bool]:
313
+ """Whether the `socket_fdinfo` layout reads back a connected pair of this process's own (the listener and both
314
+ ends, each once, with their exact addresses and this pid); None when libproc could not be read at all, which the
315
+ caller does not keep (FAA-273 item 11: an unread calibration is not a verdict)."""
316
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
317
+ client = taken = None
318
+ try:
319
+ probe.bind(("127.0.0.1", 0))
320
+ probe.listen(1)
321
+ client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
322
+ taken, _ = probe.accept()
323
+ server, near = probe.getsockname()[:2], client.getsockname()[:2]
324
+ have = own_sockets(_libproc_records(os.getuid(), time.monotonic() + TIMEOUT_SECONDS))
325
+ me = os.getpid()
326
+ return all(len(hits) == 1 and hits[0].pid == me for hits in (
327
+ [s for s in have if s.listening and s.local == server],
328
+ [s for s in have if not s.listening and s.local == server and s.remote == near],
329
+ [s for s in have if not s.listening and s.local == near and s.remote == server]))
330
+ except (ProofUnreadable, OSError, struct.error, ValueError):
331
+ return None
332
+ finally:
333
+ for s in (client, taken, probe):
334
+ if s is not None:
335
+ s.close()
336
+
337
+
338
+ def method(platform: Optional[str] = None) -> str:
339
+ """The reader the next question uses: libproc once its layout passed calibration, else lsof; none off macOS."""
340
+ if (platform or sys.platform) != "darwin":
341
+ return "none"
342
+ with _LOCK:
343
+ if _LIBPROC_OK[0] is None:
344
+ _LIBPROC_OK[0] = _calibrate_libproc()
345
+ return LIBPROC if _LIBPROC_OK[0] else LSOF_METHOD
346
+
347
+
348
+ def _ask(port: int, state: str, pick: Callable[[List[Socket], str], Proof], wait: float, platform: Optional[str],
349
+ uid: Optional[int], euid: Optional[int], how: Optional[str], run: Optional[Callable[[List[str]], str]]) -> Proof:
350
+ start = time.monotonic()
351
+ if (platform or sys.platform) != "darwin":
352
+ return Proof(False, None, "none", "%s: unsupported-platform" % TABLE_RESTRICTED)
353
+ uid = os.getuid() if uid is None else uid
354
+ euid = os.geteuid() if euid is None else euid
355
+ if uid != euid:
356
+ return Proof(False, None, how or "none", "%s: setuid-context" % TABLE_RESTRICTED)
357
+ how = how or method(platform)
358
+ pause = 0.005
359
+ while True:
360
+ try:
361
+ if how == LIBPROC:
362
+ have = own_sockets(_libproc_records(uid, start + TIMEOUT_SECONDS))
363
+ else:
364
+ argv = [LSOF, "-nP", "-a", "-u", str(uid), "-iTCP:%d" % port, "-sTCP:%s" % state, "-F", "pun"]
365
+ have = parse_lsof_sockets((run or _run)(argv), uid)
366
+ except ProofUnreadable as exc:
367
+ return Proof(False, None, how, "%s: %s" % (TABLE_RESTRICTED, exc))
368
+ answer = pick(have, how)
369
+ if answer.proved or time.monotonic() + pause > start + min(wait, TIMEOUT_SECONDS):
370
+ return answer
371
+ time.sleep(pause)
372
+ pause = min(pause * 2, 0.05)
373
+
374
+
375
+ def accepted(server: Address, client: Address, *, wait: bool = False, platform: Optional[str] = None,
376
+ uid: Optional[int] = None, euid: Optional[int] = None, how: Optional[str] = None,
377
+ run: Optional[Callable[[List[str]], str]] = None) -> Proof:
378
+ """RT-2: whether the socket at `server` connected to `client` (a client's own connection, seen from the server's
379
+ side) is in one of this account's processes. `wait`: a server end is read again for ACCEPT_WAIT_SECONDS, since a
380
+ client asks right after connect() and the server's accepted socket exists only once it calls accept().
381
+ `platform`, `uid`, `euid`, `how` and `run` are the tests'."""
382
+ server, client = (_host(str(server[0])), int(server[1])), (_host(str(client[0])), int(client[1]))
383
+ return _ask(server[1], "ESTABLISHED", lambda have, how_: accepted_in(have, server, client, how_),
384
+ ACCEPT_WAIT_SECONDS if wait else 0.0, platform, uid, euid, how, run)
385
+
386
+
387
+ def own_listener(port: int, *, platform: Optional[str] = None, uid: Optional[int] = None, euid: Optional[int] = None,
388
+ how: Optional[str] = None, run: Optional[Callable[[List[str]], str]] = None) -> Proof:
389
+ """FAA-272: whether one of this account's processes listens on exactly 127.0.0.1:<port>."""
390
+ return _ask(int(port), "LISTEN", lambda have, how_: listener_in(have, int(port), how_), 0.0, platform, uid, euid,
391
+ how, run)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Supervisors (DEC-0032, LC-14): launchd plists, systemd user units and Task Scheduler tasks, installed and
2
3
  removed without touching what the operator turned off; release pruning (LC-15). Part of the service kit
3
4
  (FAA-65, layer L3)."""
@@ -25,6 +26,7 @@ from pathlib import Path as _Path # noqa: E402
25
26
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
26
27
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
27
28
  import fabric_peer_owner as _po # noqa: E402 - the listener's owner (DEC-0154 HA-2)
29
+ import fabric_socket_proof as _sp # noqa: E402 - the one loopback host rule (FAA-326)
28
30
  from fabric_base import (
29
31
  DRAIN_SECONDS,
30
32
  SUPERVISOR_ENV,
@@ -462,16 +464,20 @@ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
462
464
  return None
463
465
 
464
466
 
465
- def _loopback_host(host: Optional[str]) -> bool:
466
- """Whether `host` (urlsplit's hostname: lower-case, no brackets) names this machine: localhost, 127/8 or ::1."""
467
- if not host:
468
- return False
469
- if host == "localhost":
470
- return True
467
+ #: Whether urlsplit's hostname names this machine: the kit's one loopback rule (FAA-326), so 127.1, 2130706433 and a
468
+ #: name under .localhost, which the Node twin's `new URL` reads as loopback, take the checked path here too.
469
+ _loopback_host = _sp.loopback_host
470
+
471
+
472
+ def _loopback_otherwise(host: Optional[str]) -> bool:
473
+ """A loopback address the kit's rule does not take: one with a zone (`::1%lo0`) or IPv4-mapped (`::ffff:127.0.0.1`).
474
+ urlopen reaches loopback through either, so it is refused unread, as Node refuses it (0.84.0 seam reading)."""
471
475
  try:
472
- return ipaddress.ip_address(host).is_loopback
476
+ address = ipaddress.ip_address((host or "").split("%", 1)[0])
473
477
  except ValueError:
474
478
  return False
479
+ mapped = getattr(address, "ipv4_mapped", None)
480
+ return bool(address.is_loopback or (mapped and mapped.is_loopback))
475
481
 
476
482
 
477
483
  class ForeignUserAnswer:
@@ -498,7 +504,7 @@ def _read_well_known(origin: str, timeout: float = 2.0,
498
504
  except ValueError: # a port out of range
499
505
  port = None
500
506
  if parts.scheme == "http" and port and _loopback_host(parts.hostname):
501
- # every loopback spelling (127.0.0.1, another 127/8 address, localhost, [::1]) goes on a checked connection,
507
+ # every loopback spelling (127/8 in any form, localhost or a name under it, [::1]) goes on a checked connection,
502
508
  # never the unchecked way (FAA-257)
503
509
  conn = _po.CheckedHTTPConnection(parts.hostname, port, timeout=timeout, vouch_pid=vouch_pid)
504
510
  try:
@@ -512,6 +518,8 @@ def _read_well_known(origin: str, timeout: float = 2.0,
512
518
  return None, None
513
519
  finally:
514
520
  conn.close()
521
+ if _loopback_otherwise(parts.hostname):
522
+ return None, None
515
523
  try:
516
524
  with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
517
525
  return json.loads(response.read().decode()), None
@@ -522,14 +530,9 @@ def _read_well_known(origin: str, timeout: float = 2.0,
522
530
  def _job_listens(pid: int, port: int) -> bool:
523
531
  """Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
524
532
  (`lsof -p`), which a restricted socket table does not hide. Used only for a LaunchDaemon root just bootstrapped,
525
- whose pid launchd itself named (FAA-278)."""
526
- if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
527
- return False
528
- try:
529
- out = _po._run([_po.LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
530
- except _po.TableUnreadable:
531
- return False
532
- return any(host == "127.0.0.1" and p == port for host, p in _po.parse_lsof_listeners(out)) # exact bind, as RT-3
533
+ whose pid launchd itself named (FAA-278). The peer owner's one lsof reader, the uid left to the caller, which checks
534
+ the job's UserName itself (FAA-286 item 1)."""
535
+ return _po.pid_listens(pid, port, owner=None)
533
536
 
534
537
 
535
538
  def _account_uid(user_name: str) -> Optional[int]:
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
3
  """Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). Standard
3
4
  library only; the Node twin is fabric-tailnet.mjs, and both answer test/fixtures/tailnet/vectors.json alike.
4
5