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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +200 -3
  2. package/README.md +11 -6
  3. package/SECURITY.md +8 -5
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  111. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  112. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  113. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  114. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  115. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  116. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  117. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  118. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  124. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  125. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
  126. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
  127. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,390 @@
1
+ """fabric_socket_proof: this OS account's own TCP sockets, read from its own processes (layer L0; DEC-0155 RT-2,
2
+ FAA-292; FAA-272).
3
+
4
+ macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew or uv CPython) only its own sockets in the host's socket
5
+ table, so `fabric_peer_owner` reads such a table as `table-restricted` and cannot tell who accepted a connection. The
6
+ question can still be asked of this account's own processes, which the restriction leaves readable: whether one of them
7
+ holds the very socket that accepted the connection (RT-2, the accepted-socket proof), or a listener bound exactly to
8
+ 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
9
+ ever says "this account's" or "not proved"; never another account's, the system's or nobody's.
10
+
11
+ accepted(server, client, wait=...) -> Proof the socket at `server` connected to `client` is in one of this
12
+ account's processes (`wait`: read again for ACCEPT_WAIT_SECONDS
13
+ until the server has called accept())
14
+ own_listener(port) -> Proof a listener of this account on exactly 127.0.0.1:<port>
15
+
16
+ Two readers, chosen by `method()`:
17
+
18
+ - `libproc` (Python's): `proc_listpids(PROC_UID_ONLY)`, `proc_pidinfo(PROC_PIDLISTFDS)` and
19
+ `proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` through ctypes, the records `lsof` itself reads, without a subprocess:
20
+ Project Observatory's `peer_owner` 1.3.1 reader (sha256 `b3831e5f...`, FAA-292). The `socket_fdinfo` layout is not
21
+ an API, so the first read in a process calibrates it on a connected pair of its own, as the sysctl reader does; a
22
+ layout that fails is never used again in this process, and a calibration that could not read at all is asked again.
23
+ - `lsof` (the Node twin's, and Python's when libproc is unavailable or uncalibrated): `lsof -nP -a -u <uid>
24
+ -iTCP:<port> -sTCP:<state> -F pun`, the exact address pair and the numeric uid. The twins agree on answers, not on
25
+ the program they run (DEC-0155 Compatibility); `test/fixtures/peer-owner/socket-proof-cases.json` holds both.
26
+
27
+ The proof is weaker than a table's `so_uid`: "a process of this uid holds the descriptor", not "the socket's
28
+ credential is this uid". A socket another account accepted and passed with SCM_RIGHTS to a process of this uid would
29
+ read as this account's; a caller that knows the server's pid closes that with `expect_pid`. One socket several of this
30
+ account's processes hold (an inherited descriptor) is proved with pid None, reason `several-processes`. A process whose
31
+ real and effective uids differ is refused (`setuid-context`): XNU lists and checks processes by the effective uid.
32
+ Reasons, as DEC-0155 RT-1 spells them: `table-restricted` (read, and no socket of this account matches) or
33
+ `table-restricted: <detail>` (the proof could not be read). Bounded by MAX_PIDS, MAX_FDS and TIMEOUT_SECONDS; nothing
34
+ is cached across questions but the calibration verdict. Standard library only, no other kit module. Tests:
35
+ `test/test_fabric_socket_proof.py`; the Node twin is `fabric-socket-proof.mjs`.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import ipaddress
40
+ import os
41
+ import socket
42
+ import struct
43
+ import subprocess
44
+ import sys
45
+ import threading
46
+ import time
47
+ from typing import Callable, List, NamedTuple, Optional, Tuple
48
+
49
+ TABLE_RESTRICTED = "table-restricted"
50
+ LIBPROC = "libproc"
51
+ LSOF_METHOD = "lsof"
52
+ LSOF = "/usr/sbin/lsof"
53
+ #: Every read of one question is over within this many seconds (the accept wait included).
54
+ TIMEOUT_SECONDS = 2.0
55
+ #: How long `accepted(..., wait=True)` reads again for a server that has not called accept() yet.
56
+ ACCEPT_WAIT_SECONDS = 0.5
57
+ MAX_PIDS = 16384
58
+ MAX_FDS = 65536
59
+ MAX_OUTPUT_BYTES = 4 * 1024 * 1024
60
+
61
+ #: macOS `sys/proc_info.h`: selectors and the `struct socket_fdinfo` offsets read here. The struct is 792 bytes:
62
+ #: `proc_fileinfo` (24), then `socket_info`, whose `soi_so` is the kernel's socket (the same in every process that
63
+ #: holds it) and whose `soi_proto.pri_tcp` starts with an `in_sockinfo` (ports as ints holding a network-order port,
64
+ #: `insi_vflag`, the foreign and local `in4in6_addr`) and carries `tcpsi_state`.
65
+ PROC_UID_ONLY = 4
66
+ PROC_PIDLISTFDS = 1
67
+ PROX_FDTYPE_SOCKET = 2
68
+ PROC_PIDFDSOCKETINFO = 3
69
+ SOCKINFO_TCP = 2
70
+ SOCKET_FDINFO_SIZE = 792
71
+ _SOI = 24 # socket_fdinfo.psi
72
+ _SOI_SO = _SOI + 136 # socket_info.soi_so
73
+ _SOI_KIND = _SOI + 232 # socket_info.soi_kind
74
+ _INI = _SOI + 240 # soi_proto.pri_tcp.tcpsi_ini
75
+ _INI_VFLAG = _INI + 24 # INI_IPV4 0x1, INI_IPV6 0x2
76
+ _INI_FOREIGN = _INI + 32
77
+ _INI_LOCAL = _INI + 48
78
+ _TCPSI_STATE = _INI + 80 # TSI_S_CLOSED 0, TSI_S_LISTEN 1, TSI_S_TIME_WAIT 10
79
+ _TSI_S_CLOSED = 0
80
+ _TSI_S_LISTEN = 1
81
+ _TSI_S_TIME_WAIT = 10
82
+
83
+ Address = Tuple[str, int]
84
+ # Real types, not postponed strings (as fabric_peer_owner's Owner): a NamedTuple's generated __new__ resolves its
85
+ # annotations in a namespace of its own, where `Address` is no name.
86
+ _SocketFields = NamedTuple("_SocketFields", [("local", Tuple[str, int]), ("remote", Tuple[str, int]),
87
+ ("listening", bool), ("pid", Optional[int])])
88
+ _ProofFields = NamedTuple("_ProofFields", [("proved", bool), ("pid", Optional[int]), ("method", str),
89
+ ("reason", Optional[str])])
90
+
91
+
92
+ class Socket(_SocketFields):
93
+ """One TCP socket of this account: `pid` is None when several of its processes hold it."""
94
+ __slots__ = ()
95
+
96
+
97
+ class Proof(_ProofFields):
98
+ """Whether this account's process holds the socket asked about; `reason` says why not, or `several-processes`."""
99
+ __slots__ = ()
100
+
101
+
102
+ class ProofUnreadable(Exception):
103
+ """This account's sockets could not be read; the message is the reason, never a socket's content."""
104
+
105
+
106
+ def _host(host: str) -> str:
107
+ """One spelling per address, as fabric_peer_owner.norm_host (the same rule, kept here so this module stands alone):
108
+ an IPv4-mapped IPv6 address is its IPv4 form, an unspecified one `*`, `localhost` 127.0.0.1, and `[...]` loses its
109
+ brackets. Anything unparseable is returned unchanged (and matches only itself)."""
110
+ if host.startswith("[") and host.endswith("]"):
111
+ host = host[1:-1]
112
+ if host in ("*", ""):
113
+ return "*"
114
+ if host == "localhost":
115
+ return "127.0.0.1"
116
+ host = host.split("%", 1)[0]
117
+ try:
118
+ ip = ipaddress.ip_address(host)
119
+ except ValueError:
120
+ return host
121
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
122
+ ip = ip.ipv4_mapped
123
+ return "*" if ip.is_unspecified else str(ip)
124
+
125
+
126
+ def _digits(text: str) -> bool:
127
+ """`[0-9]+`, as the Node twin reads a number (`str.isdigit` also takes `٤`)."""
128
+ return text != "" and text.isascii() and text.isdigit()
129
+
130
+
131
+ def loopback_host(host: str) -> bool:
132
+ """Whether a URL's host, as a URL parser leaves it (urlsplit's `hostname`, lower case, no brackets), names this
133
+ computer's loopback the way a WHATWG parser reads it: `localhost` or a name under `.localhost` (which resolves to
134
+ loopback), ::1, or an IPv4 address in 127/8 in any form `new URL` reads (`127.1`, `2130706433`, `0x7f.1`, one
135
+ trailing dot). Never an IPv4-mapped address (Node writes it in hex and refuses it), one with a zone, or whitespace.
136
+ The one rule fabric_http's check_url and fabric_delegation's key set source read (FAA-187 (15)); the Node twin's
137
+ `loopbackHost` reads `new URL`'s hostname."""
138
+ host = (host or "").lower()
139
+ if host == "localhost" or host.endswith(".localhost"):
140
+ return True
141
+ if not host or any(c.isspace() for c in host) or "%" in host:
142
+ return False
143
+ try:
144
+ address = ipaddress.ip_address(host)
145
+ return address.is_loopback and not getattr(address, "ipv4_mapped", None)
146
+ except ValueError:
147
+ pass
148
+ host = host[:-1] if host.endswith(".") else host
149
+ try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
150
+ return socket.inet_aton(host)[0] == 127
151
+ except (OSError, ValueError):
152
+ return False
153
+
154
+
155
+ # -- parsers: pure functions over captured records and text (test/fixtures/peer-owner/socket-proof-cases.json) -------
156
+
157
+ def parse_socket_fdinfo(blob: bytes) -> Optional[Tuple[Address, Address, int, int]]:
158
+ """One `proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` record: (local, remote, TCP state, socket handle) for a TCP socket,
159
+ None for any other kind. A short record is unreadable."""
160
+ if len(blob) < SOCKET_FDINFO_SIZE:
161
+ raise ProofUnreadable("libproc: short socket record")
162
+ if struct.unpack_from("<i", blob, _SOI_KIND)[0] != SOCKINFO_TCP:
163
+ return None
164
+ fport, lport = struct.unpack_from(">H", blob, _INI)[0], struct.unpack_from(">H", blob, _INI + 4)[0]
165
+ family, at = (socket.AF_INET, 12) if blob[_INI_VFLAG] & 0x1 else (socket.AF_INET6, 0)
166
+ size = 4 if family == socket.AF_INET else 16
167
+ foreign = socket.inet_ntop(family, bytes(blob[_INI_FOREIGN + at:_INI_FOREIGN + at + size]))
168
+ local = socket.inet_ntop(family, bytes(blob[_INI_LOCAL + at:_INI_LOCAL + at + size]))
169
+ state = struct.unpack_from("<i", blob, _TCPSI_STATE)[0]
170
+ return (_host(local), lport), (_host(foreign), fport), state, struct.unpack_from("<Q", blob, _SOI_SO)[0]
171
+
172
+
173
+ def own_sockets(records: List[Tuple[int, Address, Address, int, int]]) -> List[Socket]:
174
+ """Sockets from this account's records (pid, local, remote, state, handle): one per handle, pid None when several
175
+ processes hold it. Closed and TIME_WAIT sockets are left out."""
176
+ by_handle: dict = {}
177
+ for pid, local, remote, state, handle in records:
178
+ if state in (_TSI_S_CLOSED, _TSI_S_TIME_WAIT):
179
+ continue
180
+ by_handle.setdefault(handle, (local, remote, state, []))[3].append(pid)
181
+ return [Socket(local, remote, state == _TSI_S_LISTEN, pids[0] if len(set(pids)) == 1 else None)
182
+ for local, remote, state, pids in by_handle.values()]
183
+
184
+
185
+ def _lsof_address(text: str) -> Optional[Address]:
186
+ """`127.0.0.1:47311`, `[::1]:47070` or `*:47312`: the port after the last colon, ASCII digits only."""
187
+ host, sep, port = text.rpartition(":")
188
+ if not sep or not _digits(port) or int(port) > 65535:
189
+ return None
190
+ return _host(host), int(port)
191
+
192
+
193
+ def parse_lsof_sockets(text: str, uid: int) -> List[Socket]:
194
+ """`lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`: the `n` lines of every process block whose `u` line is
195
+ exactly `uid` (a block of another uid, or with no uid, is not read). A name with `->` is a connection, one without a
196
+ listener. The same socket listed by several processes (one address pair: TCP holds one connection per pair) is one
197
+ socket with pid None."""
198
+ found: dict = {}
199
+ pid = owner = None
200
+ for line in text.splitlines():
201
+ tag, value = line[:1], line[1:]
202
+ if tag == "p":
203
+ pid, owner = (int(value) if _digits(value) else None), None
204
+ elif tag == "u":
205
+ owner = int(value) if _digits(value) else None
206
+ elif tag == "n" and pid is not None and owner == uid:
207
+ local_text, arrow, remote_text = value.partition("->")
208
+ local = _lsof_address(local_text)
209
+ remote = _lsof_address(remote_text) if arrow else ("*", 0)
210
+ if local is None or remote is None:
211
+ continue
212
+ key = (local, remote, not arrow)
213
+ found.setdefault(key, []).append(pid)
214
+ return [Socket(local, remote, listening, pids[0] if len(set(pids)) == 1 else None)
215
+ for (local, remote, listening), pids in found.items()]
216
+
217
+
218
+ def _proved(hits: List[Socket], how: str) -> Proof:
219
+ if not hits:
220
+ return Proof(False, None, how, TABLE_RESTRICTED)
221
+ pids = {s.pid for s in hits}
222
+ if len(pids) == 1 and None not in pids:
223
+ return Proof(True, pids.pop(), how, None)
224
+ return Proof(True, None, how, "several-processes")
225
+
226
+
227
+ def accepted_in(sockets: List[Socket], server: Address, client: Address, how: str) -> Proof:
228
+ """RT-2 over this account's sockets: the one at `server` connected to `client`. Two different sockets with that
229
+ address pair cannot be told apart (`several-rows`)."""
230
+ hits = [s for s in sockets if not s.listening and s.local == server and s.remote == client]
231
+ if len(hits) > 1:
232
+ return Proof(False, None, how, "several-rows")
233
+ return _proved(hits, how)
234
+
235
+
236
+ def listener_in(sockets: List[Socket], port: int, how: str) -> Proof:
237
+ """FAA-272 over this account's sockets: a listener bound exactly to 127.0.0.1:<port>. XNU refuses another ordinary
238
+ account that bind beside ours (`in_pcbbind`); our wildcard or ::1 listener, and root's, cannot be told from a
239
+ hidden one, so they prove nothing (DEC-0155 RT-4)."""
240
+ return _proved([s for s in sockets if s.listening and s.local == ("127.0.0.1", port)], how)
241
+
242
+
243
+ # -- readers ----------------------------------------------------------------------------------------------------------
244
+
245
+ _LOCK = threading.Lock()
246
+ #: None until a calibration of the libproc layout reaches a verdict in this process, then True or False.
247
+ _LIBPROC_OK: list = [None]
248
+
249
+
250
+ def _run(argv: List[str]) -> str:
251
+ """One bounded run of lsof; its exit 1 with nothing printed is "no socket matched", not a failure."""
252
+ try:
253
+ done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT_SECONDS, check=False, stdin=subprocess.DEVNULL)
254
+ except (OSError, subprocess.TimeoutExpired) as exc:
255
+ raise ProofUnreadable("lsof: %s" % type(exc).__name__) from None
256
+ if done.returncode == 1 and not done.stdout.strip():
257
+ return ""
258
+ if done.returncode != 0:
259
+ raise ProofUnreadable("lsof: exit %d" % done.returncode)
260
+ if len(done.stdout) > MAX_OUTPUT_BYTES:
261
+ raise ProofUnreadable("lsof: output over %d bytes" % MAX_OUTPUT_BYTES)
262
+ return done.stdout.decode("utf-8", "replace")
263
+
264
+
265
+ def _libproc_records(uid: int, deadline: float) -> List[Tuple[int, Address, Address, int, int]]:
266
+ """This uid's processes' TCP sockets, as (pid, local, remote, state, handle). A process that cannot be read (gone,
267
+ or EPERM) is skipped: it can only lose an answer, never make one."""
268
+ try:
269
+ import ctypes
270
+ lib = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
271
+ lib.proc_listpids.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int]
272
+ lib.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int]
273
+ lib.proc_pidfdinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int]
274
+ except (ImportError, OSError, TypeError, AttributeError) as exc:
275
+ raise ProofUnreadable("libproc: %s" % type(exc).__name__) from None
276
+ for fn in (lib.proc_listpids, lib.proc_pidinfo, lib.proc_pidfdinfo):
277
+ fn.restype = ctypes.c_int
278
+ need = lib.proc_listpids(PROC_UID_ONLY, uid, None, 0)
279
+ if need <= 0:
280
+ raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
281
+ pids = (ctypes.c_int * min(need // 4 + 64, MAX_PIDS))()
282
+ got = lib.proc_listpids(PROC_UID_ONLY, uid, pids, ctypes.sizeof(pids))
283
+ if got <= 0:
284
+ raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
285
+ if got // 4 >= MAX_PIDS:
286
+ raise ProofUnreadable("libproc: over %d processes" % MAX_PIDS)
287
+ record = ctypes.create_string_buffer(SOCKET_FDINFO_SIZE)
288
+ out = []
289
+ for pid in pids[:got // 4]:
290
+ if pid <= 0:
291
+ continue
292
+ if time.monotonic() > deadline:
293
+ raise ProofUnreadable("libproc: deadline")
294
+ size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, None, 0)
295
+ if size <= 0:
296
+ continue
297
+ fds = ctypes.create_string_buffer(min(size + 32 * 8, MAX_FDS * 8))
298
+ size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, len(fds))
299
+ for i in range(max(size, 0) // 8):
300
+ fd, kind = struct.unpack_from("<iI", fds, i * 8)
301
+ if kind != PROX_FDTYPE_SOCKET:
302
+ continue
303
+ if lib.proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, record, SOCKET_FDINFO_SIZE) != SOCKET_FDINFO_SIZE:
304
+ continue
305
+ parsed = parse_socket_fdinfo(record.raw)
306
+ if parsed is not None:
307
+ out.append((pid,) + parsed)
308
+ return out
309
+
310
+
311
+ def _calibrate_libproc() -> Optional[bool]:
312
+ """Whether the `socket_fdinfo` layout reads back a connected pair of this process's own (the listener and both
313
+ ends, each once, with their exact addresses and this pid); None when libproc could not be read at all, which the
314
+ caller does not keep (FAA-273 item 11: an unread calibration is not a verdict)."""
315
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
316
+ client = taken = None
317
+ try:
318
+ probe.bind(("127.0.0.1", 0))
319
+ probe.listen(1)
320
+ client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
321
+ taken, _ = probe.accept()
322
+ server, near = probe.getsockname()[:2], client.getsockname()[:2]
323
+ have = own_sockets(_libproc_records(os.getuid(), time.monotonic() + TIMEOUT_SECONDS))
324
+ me = os.getpid()
325
+ return all(len(hits) == 1 and hits[0].pid == me for hits in (
326
+ [s for s in have if s.listening and s.local == server],
327
+ [s for s in have if not s.listening and s.local == server and s.remote == near],
328
+ [s for s in have if not s.listening and s.local == near and s.remote == server]))
329
+ except (ProofUnreadable, OSError, struct.error, ValueError):
330
+ return None
331
+ finally:
332
+ for s in (client, taken, probe):
333
+ if s is not None:
334
+ s.close()
335
+
336
+
337
+ def method(platform: Optional[str] = None) -> str:
338
+ """The reader the next question uses: libproc once its layout passed calibration, else lsof; none off macOS."""
339
+ if (platform or sys.platform) != "darwin":
340
+ return "none"
341
+ with _LOCK:
342
+ if _LIBPROC_OK[0] is None:
343
+ _LIBPROC_OK[0] = _calibrate_libproc()
344
+ return LIBPROC if _LIBPROC_OK[0] else LSOF_METHOD
345
+
346
+
347
+ def _ask(port: int, state: str, pick: Callable[[List[Socket], str], Proof], wait: float, platform: Optional[str],
348
+ uid: Optional[int], euid: Optional[int], how: Optional[str], run: Optional[Callable[[List[str]], str]]) -> Proof:
349
+ start = time.monotonic()
350
+ if (platform or sys.platform) != "darwin":
351
+ return Proof(False, None, "none", "%s: unsupported-platform" % TABLE_RESTRICTED)
352
+ uid = os.getuid() if uid is None else uid
353
+ euid = os.geteuid() if euid is None else euid
354
+ if uid != euid:
355
+ return Proof(False, None, how or "none", "%s: setuid-context" % TABLE_RESTRICTED)
356
+ how = how or method(platform)
357
+ pause = 0.005
358
+ while True:
359
+ try:
360
+ if how == LIBPROC:
361
+ have = own_sockets(_libproc_records(uid, start + TIMEOUT_SECONDS))
362
+ else:
363
+ argv = [LSOF, "-nP", "-a", "-u", str(uid), "-iTCP:%d" % port, "-sTCP:%s" % state, "-F", "pun"]
364
+ have = parse_lsof_sockets((run or _run)(argv), uid)
365
+ except ProofUnreadable as exc:
366
+ return Proof(False, None, how, "%s: %s" % (TABLE_RESTRICTED, exc))
367
+ answer = pick(have, how)
368
+ if answer.proved or time.monotonic() + pause > start + min(wait, TIMEOUT_SECONDS):
369
+ return answer
370
+ time.sleep(pause)
371
+ pause = min(pause * 2, 0.05)
372
+
373
+
374
+ def accepted(server: Address, client: Address, *, wait: bool = False, platform: Optional[str] = None,
375
+ uid: Optional[int] = None, euid: Optional[int] = None, how: Optional[str] = None,
376
+ run: Optional[Callable[[List[str]], str]] = None) -> Proof:
377
+ """RT-2: whether the socket at `server` connected to `client` (a client's own connection, seen from the server's
378
+ side) is in one of this account's processes. `wait`: a server end is read again for ACCEPT_WAIT_SECONDS, since a
379
+ client asks right after connect() and the server's accepted socket exists only once it calls accept().
380
+ `platform`, `uid`, `euid`, `how` and `run` are the tests'."""
381
+ server, client = (_host(str(server[0])), int(server[1])), (_host(str(client[0])), int(client[1]))
382
+ return _ask(server[1], "ESTABLISHED", lambda have, how_: accepted_in(have, server, client, how_),
383
+ ACCEPT_WAIT_SECONDS if wait else 0.0, platform, uid, euid, how, run)
384
+
385
+
386
+ def own_listener(port: int, *, platform: Optional[str] = None, uid: Optional[int] = None, euid: Optional[int] = None,
387
+ how: Optional[str] = None, run: Optional[Callable[[List[str]], str]] = None) -> Proof:
388
+ """FAA-272: whether one of this account's processes listens on exactly 127.0.0.1:<port>."""
389
+ return _ask(int(port), "LISTEN", lambda have, how_: listener_in(have, int(port), how_), 0.0, platform, uid, euid,
390
+ how, run)
@@ -522,14 +522,9 @@ def _read_well_known(origin: str, timeout: float = 2.0,
522
522
  def _job_listens(pid: int, port: int) -> bool:
523
523
  """Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
524
524
  (`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
525
+ whose pid launchd itself named (FAA-278). The peer owner's one lsof reader, the uid left to the caller, which checks
526
+ the job's UserName itself (FAA-286 item 1)."""
527
+ return _po.pid_listens(pid, port, owner=None)
533
528
 
534
529
 
535
530
  def _account_uid(user_name: str) -> Optional[int]: