@passioncode-ai/fabric-agent-adapter 0.82.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 (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -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_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -390,12 +390,24 @@ def package_findings(pkg: Dict[str, Any]) -> List[Dict[str, str]]:
390
390
 
391
391
  # --- the folder check and the verifier ------------------------------------------------------------------
392
392
 
393
+ def _json_int(text: str) -> Any:
394
+ """An integer literal as JSON.parse reads it: one longer than a Python with `int_max_str_digits` reads is a float,
395
+ as a JavaScript number is (FAA-194 (36))."""
396
+ return int(text) if len(text) <= 4000 else float(text)
397
+
398
+
393
399
  def _parsed(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
400
+ """A plugin or provider manifest as the contract's checkPackageFolder reads it: decoded with replacement characters
401
+ (`Buffer.toString`, FAA-194 (29)); NaN or Infinity, or nesting past MAX_NESTING (the limit both kits share, FAA-194
402
+ (27)), is no manifest."""
394
403
  if data is None:
395
404
  return None
405
+ text = data.decode("utf-8", "replace")
406
+ if _nesting(text) > MAX_NESTING:
407
+ return None
396
408
  try:
397
- value = json.loads(data.decode("utf-8"), parse_constant=fd._no_constant)
398
- except (ValueError, RecursionError): # invalid UTF-8, NaN or Infinity is no manifest, in both kits
409
+ value = json.loads(text, parse_constant=fd._no_constant, parse_int=_json_int)
410
+ except (ValueError, RecursionError):
399
411
  return None
400
412
  return value if isinstance(value, dict) else None
401
413
 
@@ -419,7 +431,7 @@ def check_package_folder(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[D
419
431
  problems.append({"problem": "file-unlisted", "path": path})
420
432
  if PLUGIN_MANIFEST in files:
421
433
  plugin = _parsed(files.get(PLUGIN_MANIFEST))
422
- if not plugin or not _same_member(plugin, "name", pkg.get("id", _UNDEFINED)) or not _same_member(plugin, "version", pkg.get("version", _UNDEFINED)):
434
+ if plugin is None or not _same_member(plugin, "name", pkg.get("id", _UNDEFINED)) or not _same_member(plugin, "version", pkg.get("version", _UNDEFINED)):
423
435
  problems.append({"problem": "plugin-manifest", "path": PLUGIN_MANIFEST})
424
436
  provider = pkg.get("provider")
425
437
  if isinstance(provider, str) and provider in files:
@@ -615,7 +627,7 @@ def read_folder(folder: Any) -> Dict[str, bytes]:
615
627
  if _BY_FOLDER:
616
628
  os.close(top) # type: ignore[arg-type]
617
629
  if refused:
618
- raise PackageError("Not packed: remove these or pack a clean copy: " + "; ".join(sorted(refused)))
630
+ raise PackageError("Not packed: pack a clean copy (what Git would commit, references/package.md); delete only litter and credential files in place: " + "; ".join(sorted(refused)))
619
631
  return files
620
632
 
621
633
 
@@ -38,23 +38,35 @@ def services_dir() -> Path:
38
38
  return Path(base) / "passioncode-fabric/services"
39
39
 
40
40
 
41
+ def _durable(service_id: str, dirs: Dict[str, Path]) -> Dict[str, Path]:
42
+ """FABRIC_DATA_HOME (FAA-192): an absolute folder on a durable volume holds the data folder, `<home>/<id>`, on a host
43
+ whose own disk does not persist; logs and cache stay where the OS puts them. A relative value is refused."""
44
+ value = os.environ.get("FABRIC_DATA_HOME")
45
+ if not value:
46
+ return dirs
47
+ if not os.path.isabs(value):
48
+ raise ServiceError("FABRIC_DATA_HOME is an absolute folder on a durable volume.")
49
+ return dict(dirs, data=Path(value) / service_id)
50
+
51
+
41
52
  def service_dirs(service_id: str) -> Dict[str, Path]:
42
- """State lives outside code: data+config, logs and cache in per-user OS directories."""
53
+ """State lives outside code: data+config, logs and cache in per-user OS directories (the data folder under
54
+ FABRIC_DATA_HOME when it is set)."""
43
55
  _require_id(service_id)
44
56
  home = Path.home()
45
57
  if sys.platform == "darwin":
46
- return {
58
+ return _durable(service_id, {
47
59
  "data": home / "Library/Application Support" / service_id,
48
60
  "logs": home / "Library/Logs" / service_id,
49
61
  "cache": home / "Library/Caches" / service_id,
50
- }
62
+ })
51
63
  if sys.platform == "win32":
52
64
  data = _local_app_data() / service_id
53
- return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
65
+ return _durable(service_id, {"data": data, "logs": data / "Logs", "cache": data / "Cache"})
54
66
  data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
55
67
  state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
56
68
  cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
57
- return {"data": data, "logs": state / "logs", "cache": cache}
69
+ return _durable(service_id, {"data": data, "logs": state / "logs", "cache": cache})
58
70
 
59
71
 
60
72
  def instance_dirs(service_id: str, instance: str = "default") -> Dict[str, Path]:
@@ -69,6 +81,9 @@ def instance_dirs(service_id: str, instance: str = "default") -> Dict[str, Path]
69
81
  folder, personal = "%s.%s" % (service_id, instance), service_dirs(service_id)
70
82
  if sys.platform == "win32":
71
83
  data = personal["data"].parent / folder
84
+ if os.environ.get("FABRIC_DATA_HOME"): # logs and cache stay on the local disk, beside the default's
85
+ local = personal["logs"].parent.parent / folder
86
+ return {"data": data, "logs": local / "Logs", "cache": local / "Cache"}
72
87
  return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
73
88
  if sys.platform == "darwin":
74
89
  return {key: path.parent / folder for key, path in personal.items()}
@@ -1,25 +1,22 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.2.0+kit.6: Observatory's peer_owner 1.2.0 (passioncode-ai/project-observatory-dashboard,
4
- `observatory/engine/peer_owner.py`, first vendored at f86f0622, read against 2180235c; the same licence), the kit's
5
- `fabric_peer_owner` (layer L0; DEC-0154 HA-2, FAA-226, FAA-258). Observatory's 1.3.x (this account's own sockets
6
- through libproc, DEC-0155 RT-2) is not in it (FAA-292). The kit's differences: `Owner` and `Row` are NamedTuples
7
- (a dataclass with postponed annotations needs its module in sys.modules, and a service may load a kit file by
8
- path); `Optional[X]` for `X | None` (the kit's floor is Python 3.9); `classify` / `classify_sid` ask for this
9
- process's own uid or SID first (a client running as root or SYSTEM owns root's listener); on Linux `server_end`
10
- reads an accepted connection's own row and, for one not accept()ed yet (uid 0, inode 0), the listener on its local
11
- address (CT-254; reasons `not-accepted`, `several-rows`, `closed`), `peer` reads a client row with inode 0 as
12
- unknown, several rows of one owner are that owner, a row printed twice counts once, and a `/proc` question that
13
- finds no row or several reads once more; ctypes is imported where used. On macOS (kit.2 to kit.4, FAA-267,
14
- FAA-273): the sysctl calibration also requires a child process's listener, an empty or header-only netstat is
15
- `TABLE_RESTRICTED`, a caller may vouch for a listener with a pid it started (`pid_listens`,
16
- `CheckedHTTPConnection(vouch_pid=...)`), the child's port is read under one deadline with `selectors`, a number is
17
- ASCII digits only. DEC-0155 (kit.6, FAA-312): a netstat that fails, a table with no row of another uid (sysctl) or process
18
- (netstat) and a missing connection row read as `table-restricted` (RT-1); a restricted table's listener is this
19
- account's only on exactly 127.0.0.1 (RT-4); the vouch takes exactly 127.0.0.1 (RT-3), never from a set-uid
20
- reader. `main` is the CLI, `listener <port>` (FAA-315). /proc carries a socket's creator, so a listener root created
21
- and handed to another account (systemd socket activation, User=) reads as root's. Standard library only, no other
22
- kit module. Tests: `test/test_fabric_peer_owner.py`; the Node twin is `fabric-peer-owner.mjs` (no ctypes).
3
+ peer_owner 1.3.1+kit.7: Observatory's peer_owner (passioncode-ai/project-observatory-dashboard,
4
+ `observatory/engine/peer_owner.py`, vendored at f86f0622, read against 2180235c for 1.2.0 and a69c6fee for 1.3.1),
5
+ the kit's `fabric_peer_owner` (layer L0; DEC-0154 HA-2, FAA-226, FAA-258). 1.3.1's proof from this account's own
6
+ sockets (DEC-0155 RT-2) is the kit's `fabric_socket_proof`, asked when it sits beside this file (FAA-292); without
7
+ it this file stands alone, as before. The kit's differences: NamedTuples (a service may load a kit file by path);
8
+ the Python 3.9 floor; `classify` / `classify_sid` ask for this process's uid or SID first (a client running as root
9
+ owns root's listener); on Linux `server_end` reads an accepted connection's own row and, for one not accept()ed yet
10
+ (uid 0, inode 0), the listener on its local address (CT-254), several rows of one owner are that owner, a row
11
+ printed twice counts once, and a `/proc` question that finds no row or several reads once more. On macOS (FAA-267,
12
+ FAA-273): the sysctl calibration also requires a child's listener, read under one deadline with `selectors`; a
13
+ number is ASCII digits only; a caller may vouch for a listener with a pid it started (`pid_listens`,
14
+ `CheckedHTTPConnection(vouch_pid=...)`, RT-3, the fallback after RT-2). DEC-0155 (FAA-312): a netstat that fails or
15
+ is empty, a table with no row of another uid (sysctl) or process (netstat) and a missing connection row read as
16
+ `table-restricted` (RT-1); a restricted table's listener is this account's only on exactly 127.0.0.1 (RT-4); no
17
+ proof is taken from a set-uid reader. `main` is the CLI, `listener <port>` (FAA-315). /proc carries a socket's
18
+ creator, so a listener root created and handed to another account (socket activation) reads as root's. Tests:
19
+ `test/test_fabric_peer_owner.py`; the Node twin is `fabric-peer-owner.mjs`.
23
20
 
24
21
  WHY. Loopback is one per machine, not one per OS account. On a host with several accounts, every account's processes
25
22
  reach every service any account listens with on 127.0.0.1, and a loopback socket proves nothing about who is on the
@@ -29,17 +26,14 @@ other end. This module answers that question from the host's socket table, read
29
26
  server_end(sock) -> Owner for a client's connected socket: who accepted it
30
27
  peer(sock) -> Owner for a server's accepted socket: who connected
31
28
  peer_at(server, client) the same as `peer`, from two (host, port) addresses
32
- own_connection(port, ...) a CheckedHTTPConnection, connected: every connect, the first and
33
- any reconnect http.client makes after `Connection: close`, checks
34
- that the server end is this account's, or raises ListenerRefused
35
- before a byte of the request is sent
29
+ own_connection(port, ...) a CheckedHTTPConnection, connected: every connect (the first, and any reconnect
30
+ http.client makes) checks the server end is this account's before a byte is sent
36
31
 
37
32
  `Owner.kind` is one of:
38
33
 
39
34
  this-account the socket belongs to the account running this process
40
35
  another-account another OS account's
41
- system uid 0, pid 1 (launchd holds the sockets of socket-activated jobs) or, on
42
- Windows, the system's own accounts and processes
36
+ system uid 0, pid 1 (launchd's socket-activated jobs) or, on Windows, the system's accounts and processes
43
37
  unknown the owner could not be told; `reason` says why
44
38
  none `listener` only: nothing listens on that port
45
39
 
@@ -52,36 +46,28 @@ answer; two live rows with the same addresses are `unknown` (`several-rows`), ne
52
46
 
53
47
  - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list `netstat` itself reads.
54
48
  Each connection is an `xinpcb_n` (addresses and ports) followed by an `xsocket_n` that carries `so_uid` and
55
- `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are
56
- paired by the pcb handle (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid
57
- equal to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state (`t_state`), so listeners
58
- and TIME_WAIT rows are told apart. The layout is not an API, so the first read in a process calibrates it: it opens
59
- a connected pair of its own (listen, connect, accept) and the listener and both ends of the pair must read back with
60
- their exact addresses, this process's uid and its pid. A layout that fails calibration is not used again in that
61
- process.
62
- - macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python) only its own sockets: the calibration also
63
- requires a child's listener, and a restricted table (DEC-0155 RT-1) is `unknown` with a reason beginning
64
- `table-restricted`, never another account's. A caller that started the server (an installer, its launchd job) may
65
- vouch with that pid: `lsof -p PID` asks the process, which the restriction leaves readable (RT-3).
49
+ `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and `bsd/sys/socketvar.h`, both under `#pragma pack(4)`), paired by the pcb
50
+ handle (`xi_inpp` equals `so_pcb`); the `xtcpcb_n` that follows gives the TCP state. Measured on macOS 26: about 4 ms
51
+ for 770 sockets. The layout is not an API, so the first read in a process calibrates it on a connected pair of its
52
+ own (the listener and both ends must read back with their exact addresses, this uid and this pid); a layout that
53
+ fails is not used again in that process.
54
+ - macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python) only its own sockets: the calibration also requires
55
+ a child's listener, and a restricted table (DEC-0155 RT-1) is `unknown` with a reason beginning `table-restricted`,
56
+ never another account's, unless `fabric_socket_proof` finds the socket in this account's own processes (RT-2) or a
57
+ caller that started the server vouches with its pid, which `lsof -p PID` asks the process itself about (RT-3).
66
58
  - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then `ps -o uid= -p PID` for
67
- the owner of the one row a question needs. Measured: about 20 ms over 846 sockets, and it names the pids of root's
68
- listeners, which `lsof` run by an ordinary account cannot see. Another local service implemented the same check this
69
- way; it is kept as the documented fallback for a macOS whose record layout the calibration refuses.
59
+ the one row a question needs; about 20 ms over 846 sockets, and it names the pids of root's listeners, which `lsof`
60
+ run by an ordinary account cannot see. Kept for a macOS whose record layout the calibration refuses.
70
61
  - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may read (also under
71
- `hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is only readable for this account's own
72
- processes, and the decision needs the uid alone.
73
- - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning pid;
74
- `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
75
- `GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process that cannot be opened
76
- (another account's, usually) is `unknown`, never `another-account`: the caller refuses either way, and the reason
77
- says which it was. `Owner.uid` is None there.
62
+ `hidepid=2`). No pid: that is only readable for this account's own processes, and the decision needs the uid alone.
63
+ - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning pid; `OpenProcess`,
64
+ `OpenProcessToken` and `GetTokenInformation(TokenUser)` its account SID, compared with this process's. A process that
65
+ cannot be opened (another account's, usually) is `unknown`, never `another-account`. `Owner.uid` is None there.
78
66
  - Anything else: `unknown`, reason `unsupported-platform`.
79
67
 
80
- `LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain sockets only, not for TCP,
81
- which is why a socket table is read here at all.
82
-
83
- WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a name, never sends
84
- anything, and never needs a privilege. It reports the process names `netstat` prints only as far as the pid behind them.
68
+ `LOCAL_PEERCRED`, `getpeereid` and `SO_PEERCRED` answer this for Unix-domain sockets only, hence a socket table. It
69
+ never reads a byte from the socket it is asked about, never resolves a name, never sends anything, never needs a
70
+ privilege, and reports the process names `netstat` prints only as far as the pid behind them.
85
71
  """
86
72
  from __future__ import annotations
87
73
 
@@ -98,7 +84,7 @@ import time
98
84
  from typing import NamedTuple, Optional, Tuple
99
85
 
100
86
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
101
- PEER_OWNER_VERSION = "1.2.0+kit.6" # Observatory's 1.2.0 with the kit's differences above
87
+ PEER_OWNER_VERSION = "1.3.1+kit.7" # Observatory's 1.3.1 line with the kit's differences above
102
88
 
103
89
  THIS_ACCOUNT = "this-account"
104
90
  ANOTHER_ACCOUNT = "another-account"
@@ -201,11 +187,6 @@ def _posix_uid() -> Optional[int]:
201
187
  return os.getuid() if hasattr(os, "getuid") else None
202
188
 
203
189
 
204
- def _own_pid() -> int:
205
- """This process's pid: a netstat that shows no other process's row is a restricted table (DEC-0155 RT-1)."""
206
- return os.getpid()
207
-
208
-
209
190
  def _set_uid() -> bool:
210
191
  """Real and effective uid differ: such a reader uses none of DEC-0155's proofs (RT-2 to RT-5), the vouch included."""
211
192
  return hasattr(os, "geteuid") and os.geteuid() != _posix_uid()
@@ -529,10 +510,9 @@ def _child_line(child: "subprocess.Popen[bytes]", deadline: float) -> Optional[b
529
510
 
530
511
 
531
512
  def _child_listener_seen(rows_of) -> Optional[bool]:
532
- """Whether the table shows a listener of a child process of this one: False when it does not (the table shows
533
- this process alone, FAA-267), None when no child could be started (a frozen or embedded host has no interpreter to
534
- start), or it gave no port line within TIMEOUT_SECONDS (silence, a partial or non-numeric line, an early exit, a
535
- pipe that cannot be read). The calibration trusts only True."""
513
+ """Whether the table shows a listener of a child process of this one: False when not (it shows this process alone,
514
+ FAA-267), None when no child could be started (a frozen or embedded host) or it gave no port line within
515
+ TIMEOUT_SECONDS (silence, a partial line, an early exit, an unreadable pipe). The calibration trusts only True."""
536
516
  if not sys.executable or getattr(sys, "frozen", False):
537
517
  return None
538
518
  try:
@@ -780,7 +760,7 @@ def restricted(rows: list[Row], how: str, *, own_uid: Optional[int] = None, own_
780
760
  own = _posix_uid() if own_uid is None else own_uid
781
761
  return not any(row.uid is not None and row.uid != own for row in rows)
782
762
  if how == "netstat":
783
- own = _own_pid() if own_pid is None else own_pid
763
+ own = os.getpid() if own_pid is None else own_pid
784
764
  return not any(row.pid is not None and row.pid != own for row in rows)
785
765
  return False
786
766
 
@@ -810,10 +790,9 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
810
790
  if len(found) > 1:
811
791
  return Owner(UNKNOWN, None, None, how, "several-rows")
812
792
  if how == "proc" and server and found[0].uid is None:
813
- # Linux: a connection not accept()ed yet (uid 0, inode 0) belongs to the listener on its local address (exact,
814
- # else the wildcard) and dies with it (inet_csk_listen_stop); an accepted one's own row carries the accepting
815
- # process's uid (sock_graft), so a listener swapped after another account accepted cannot pass (Observatory's
816
- # 1.2.0, board thread ced00f6e; CT-254). An orphaned server row reads so too: nothing sent to it reaches anyone.
793
+ # Linux: a connection not accept()ed yet (uid 0, inode 0) belongs to the listener on its local address (exact, else
794
+ # the wildcard) and dies with it; an accepted one's own row carries the accepting process's uid (sock_graft), so a
795
+ # listener swapped after another account accepted cannot pass (CT-254). An orphaned server row reads so too.
817
796
  return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
818
797
  if how == "proc" and found[0].uid is None:
819
798
  # A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
@@ -842,10 +821,32 @@ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) ->
842
821
  return Owner(UNKNOWN, None, None, how, "several-rows")
843
822
 
844
823
 
824
+ _PROOF: list = [None] # fabric_socket_proof once looked for: None not yet, False not beside this file (FAA-292)
825
+
826
+
827
+ def _proved(ask: str, *args, fallback: Owner, **kwargs) -> Owner:
828
+ """`fallback`, unless it is a restricted macOS table's `unknown` and `fabric_socket_proof` beside this file finds the
829
+ socket (`accepted`) or the 127.0.0.1 listener (`own_listener`, FAA-272) in this account's processes: else it stands."""
830
+ if fallback.kind != UNKNOWN or not (fallback.reason or "").startswith(TABLE_RESTRICTED) or sys.platform != "darwin":
831
+ return fallback
832
+ try:
833
+ if _PROOF[0] is None:
834
+ sys.path.append(os.path.dirname(os.path.abspath(__file__))) # beside this file, as the kit's modules load
835
+ import fabric_socket_proof
836
+ _PROOF[0] = fabric_socket_proof
837
+ proof = getattr(_PROOF[0], ask)(*args, **kwargs) if _PROOF[0] else None
838
+ except ImportError:
839
+ _PROOF[0], proof = False, None
840
+ except Exception: # noqa: BLE001 - a proof that fails proves nothing; the table's answer stands (fail closed)
841
+ proof = None
842
+ return Owner(THIS_ACCOUNT, _posix_uid(), proof.pid, proof.method, proof.reason) if proof and proof.proved else fallback
843
+
844
+
845
845
  def peer_at(server: tuple, client: tuple) -> Owner:
846
846
  """The owner of the CLIENT socket of the connection between `server` and `client`, as a server sees them (its own
847
847
  address, the peer's): the row whose local address is the client's and whose remote address is the server's."""
848
- return _owner_of(_address(client), _address(server))
848
+ client, server = _address(client), _address(server)
849
+ return _proved("accepted", client, server, wait=False, fallback=_owner_of(client, server))
849
850
 
850
851
 
851
852
  def peer(sock: socket.socket) -> Owner:
@@ -859,14 +860,14 @@ def peer(sock: socket.socket) -> Owner:
859
860
 
860
861
  def server_end(sock: socket.socket) -> Owner:
861
862
  """For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
862
- address is this socket's own, which is the server process's accepted socket, so a listener swapped after `listen`
863
- cannot pass for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0):
864
- its listener answers (the contract's CT-254, HA-2's Linux source)."""
863
+ address is this socket's own, the server process's accepted socket, so a listener swapped after `listen` cannot
864
+ pass; on Linux a connection not accept()ed yet answers through its listener (CT-254); RT-2 under a restricted table."""
865
865
  try:
866
866
  own, server = sock.getsockname(), sock.getpeername()
867
867
  except OSError as exc:
868
868
  return Owner(UNKNOWN, None, None, "none", f"socket: {type(exc).__name__}")
869
- return _owner_of(_address(server), _address(own), server=True)
869
+ server, own = _address(server), _address(own)
870
+ return _proved("accepted", server, own, wait=True, fallback=_owner_of(server, own, server=True))
870
871
 
871
872
 
872
873
  #: port -> (time.monotonic() of the read, the Owner)
@@ -899,6 +900,7 @@ def listener(port: int) -> Owner:
899
900
  owner = Owner(UNKNOWN, None, None, method(), str(exc))
900
901
  else:
901
902
  owner = _restricted_listener(rows, port, how) if restricted(rows, how) else _one_owner(pick_listener(rows, port), how)
903
+ owner = _proved("own_listener", port, fallback=owner)
902
904
  _LISTENERS[port] = (now, owner)
903
905
  return owner
904
906
 
@@ -922,17 +924,21 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
922
924
  return None
923
925
 
924
926
 
925
- def pid_listens(pid: int, port: int) -> bool:
926
- """macOS, when the socket table is restricted (FAA-267, DEC-0155 RT-3): whether process `pid`, this account's,
927
- listens on exactly 127.0.0.1:<port> (never the IPv4 wildcard, which `lsof` prints as it prints an IPv6-only one, nor
928
- ::1; FAA-312). `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that
929
- started `pid` itself (a launchd job it installed) may vouch with it, and never a set-uid one."""
930
- if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or _set_uid():
927
+ def pid_listens(pid: int, port: int, owner: object = THIS_ACCOUNT) -> bool:
928
+ """macOS, a restricted table (FAA-267, DEC-0155 RT-3): whether process `pid` listens on exactly 127.0.0.1:<port>
929
+ (never the IPv4 wildcard, which `lsof` prints as an IPv6-only one, nor ::1), asked of the process (`lsof -p`). Only
930
+ a caller that started `pid` (a launchd job it installed) vouches with it, never a set-uid one for this account. `owner`:
931
+ the uid `pid` runs as, this account's by default, or None when the caller checks it (a LaunchDaemon's UserName)."""
932
+ if owner is not None and owner != THIS_ACCOUNT and (not isinstance(owner, int) or isinstance(owner, bool) or owner < 0):
933
+ raise ValueError("owner is this-account, a uid or None")
934
+ if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or (
935
+ owner == THIS_ACCOUNT and _set_uid()):
931
936
  return False
932
937
  try:
933
- uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
934
- if uid is None or uid != _posix_uid():
935
- return False
938
+ if owner is not None:
939
+ uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
940
+ if uid is None or uid != (_posix_uid() if owner == THIS_ACCOUNT else owner):
941
+ return False
936
942
  out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
937
943
  except TableUnreadable:
938
944
  return False
@@ -940,9 +946,8 @@ def pid_listens(pid: int, port: int) -> bool:
940
946
 
941
947
 
942
948
  class CheckedHTTPConnection(http.client.HTTPConnection):
943
- """An `http.client.HTTPConnection` whose every `connect()` checks the server end before returning: `http.client`
944
- connects again by itself (`auto_open`) after `Connection: close` or a close, and that connection may reach another
945
- listener. A refused connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
949
+ """An `http.client.HTTPConnection` whose every `connect()` (the reconnect `auto_open` makes too, which may reach
950
+ another listener) checks the server end: refused, it closes the socket and raises ListenerRefused before a byte."""
946
951
 
947
952
  def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
948
953
  vouch_pid: Optional[int] = None) -> None:
@@ -957,7 +962,7 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
957
962
  super().connect()
958
963
  owner = server_end(self.sock)
959
964
  if (owner.kind == UNKNOWN and (owner.reason or "").startswith(TABLE_RESTRICTED) and getattr(self, "vouch_pid", None)
960
- and pid_listens(self.vouch_pid, self.port)):
965
+ and _address(self.sock.getpeername())[0] == "127.0.0.1" and pid_listens(self.vouch_pid, self.port)):
961
966
  # The table hides other processes (FAA-267); the process this caller started holds the listener.
962
967
  owner = Owner(THIS_ACCOUNT, _posix_uid(), self.vouch_pid, "lsof-pid")
963
968
  refused = refusal(owner, self.port, self.expect_pid)
@@ -969,14 +974,9 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
969
974
  def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
970
975
  host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
971
976
  """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's (and, with `expect_pid`,
972
- that process's). The check runs on the very connection the caller then sends on, so no listener can change between
973
- the check and the request, and again on every reconnect `http.client` makes. No proxy is consulted. Raises
974
- ListenerRefused after closing the connection:
975
-
976
- server-not-own another account's or the system's process accepted it
977
- server-unverified its owner could not be told (nothing is sent: fail closed)
978
- server-not-this-workspace this account's process, but not the pid expected
979
-
977
+ that process's), checked on the very connection the caller sends on and on every reconnect; no proxy is consulted.
978
+ ListenerRefused, the connection closed: `server-not-own` (another account's or the system's process accepted it),
979
+ `server-unverified` (its owner could not be told: fail closed), `server-not-this-workspace` (not the pid expected).
980
980
  `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started (FAA-267)."""
981
981
  conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
982
982
  conn.connect()
@@ -31,7 +31,7 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
31
31
  }
32
32
  #: ECMAScript's \s, as a character class body.
33
33
  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
34
+ NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
35
35
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
36
36
  _URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
37
37
 
@@ -74,14 +74,17 @@ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,25
74
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
75
75
  #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
76
76
  #: 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 + ")")
77
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
78
78
  _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,
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
80
  re.IGNORECASE | re.ASCII)
81
81
  _PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
82
82
  _WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
83
83
  _ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
84
- _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
85
88
  #: WHATWG's special schemes that carry an authority with user information (file has none).
86
89
  _SPECIAL = ("ftp", "http", "https", "ws", "wss")
87
90
 
@@ -108,13 +111,17 @@ def credential_name(name: str) -> bool:
108
111
  return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
109
112
 
110
113
 
111
- 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)."""
112
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))
113
117
 
114
118
 
119
+ _long_secret = long_secret # its name before FAA-190 (2)
120
+
121
+
115
122
  def secret_value(value: str) -> bool:
116
123
  """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))
124
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret(value))
118
125
 
119
126
 
120
127
  def _path_like(piece: str) -> bool:
@@ -126,13 +133,13 @@ def _path_like(piece: str) -> bool:
126
133
  def secret_argument(value: str) -> bool:
127
134
  """The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
128
135
  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):
136
+ if has_jwt(value) or SECRET_VALUE.search(value) or BEARER_INSIDE.search(value):
130
137
  return True
131
138
  for piece in _ARGUMENT_SPLIT.split(value):
132
139
  if _path_like(piece):
133
- 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)):
134
141
  return True
135
- elif _long_secret(piece):
142
+ elif long_secret(piece):
136
143
  return True
137
144
  return False
138
145
 
@@ -421,7 +428,7 @@ def _url_credential(value: str, hosts: bool) -> bool:
421
428
  # What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
422
429
  # refuses), then remove every tab, CR and LF.
423
430
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
424
- if not _SCHEME.match(value):
431
+ if not SCHEME.match(value):
425
432
  return False # not an absolute URL (WHATWG URL parsing refuses it)
426
433
  if hosts and url_refused(value):
427
434
  return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
@@ -442,7 +449,25 @@ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
442
449
  #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
443
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,}"
444
451
  _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
445
- _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
446
471
 
447
472
 
448
473
  def repository_name(piece: str) -> bool:
@@ -450,7 +475,7 @@ def repository_name(piece: str) -> bool:
450
475
  key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
451
476
  readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
452
477
  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)
478
+ return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not jwt_at_a_word(piece)
454
479
 
455
480
 
456
481
  def ascii_decoded(text: str) -> str: