@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
@@ -1,110 +1,79 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.2.0+kit.5: Observatory's peer_owner 1.2.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
- DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py`, first at
5
- commit f86f0622 (1.1.0) and read against commit 2180235c (1.2.0, passioncode-ai/project-observatory-dashboard, the
6
- same licence): the kit's line names 1.2.0 since kit.5 (FAA-258), whose Linux rule kit.1 to kit.4 already carried
7
- and the contract's CT-254 states (v0.1.0+2026-11-07.3). Observatory's 1.3.x (this account's own sockets through
8
- libproc, the contract's DEC-0155 RT-2) is not in it yet (FAA-292). It differs
9
- from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
10
- frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
11
- and a service may load a kit file by its path without one), and in writing `Optional[X]`
12
- for `X | None` in its signatures (the kit's floor is Python 3.9, where `typing.get_type_hints`
13
- cannot read `X | None`), in `classify` / `classify_sid` asking for this process's own uid or
14
- SID before the system's (a client running as root or SYSTEM owns root's listener), and in
15
- two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
16
- and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
17
- local address (state 0A; 1.2.0's rule, where the kit names its reasons `not-accepted`, `several-rows` and `closed`
18
- and Observatory `no-listener`, `listener-no-socket`, `listeners-disagree` and `no-socket`), `peer` reads a client
19
- row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
20
- a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
21
- finds no row or several reads the table once more; ctypes is imported where it is used, so a
22
- Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
23
- macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
24
- `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
25
- `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
26
- the child's port is waited on with `selectors` and read under one deadline (FAA-273); since kit.4 a number is ASCII
27
- digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
28
- A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
29
- created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
30
- The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
31
- its Node twin is `fabric-peer-owner.mjs` (netstat, /proc and PowerShell, no ctypes).
32
-
33
- WHY. Loopback is one per machine, not one per OS account. On a host with several accounts,
34
- every account's processes reach every service any account listens with on 127.0.0.1, and a
35
- loopback socket proves nothing about who is on the other end. This module answers that
36
- question from the host's socket table, read without privileges:
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`.
20
+
21
+ WHY. Loopback is one per machine, not one per OS account. On a host with several accounts, every account's processes
22
+ reach every service any account listens with on 127.0.0.1, and a loopback socket proves nothing about who is on the
23
+ other end. This module answers that question from the host's socket table, read without privileges:
37
24
 
38
25
  listener(port) -> Owner who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
39
26
  server_end(sock) -> Owner for a client's connected socket: who accepted it
40
27
  peer(sock) -> Owner for a server's accepted socket: who connected
41
28
  peer_at(server, client) the same as `peer`, from two (host, port) addresses
42
- own_connection(port, ...) a CheckedHTTPConnection, connected: every connect, the first and
43
- any reconnect http.client makes after `Connection: close`, checks
44
- that the server end is this account's, or raises ListenerRefused
45
- 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
46
31
 
47
32
  `Owner.kind` is one of:
48
33
 
49
34
  this-account the socket belongs to the account running this process
50
35
  another-account another OS account's
51
- system uid 0, pid 1 (launchd holds the sockets of socket-activated jobs) or, on
52
- 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
53
37
  unknown the owner could not be told; `reason` says why
54
38
  none `listener` only: nothing listens on that port
55
39
 
56
- A caller that must not leak (a client about to send a bearer, a server on a multi-account
57
- host about to read a request) treats everything but `this-account` as a refusal, `unknown`
58
- included: fail closed.
59
-
60
- HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the
61
- answer is never cached across connections; only `listener` is cached, for LISTENER_TTL. A
62
- TIME_WAIT row is a connection already closed and is never an answer; two live rows with the same
63
- addresses are `unknown` (`several-rows`), never a guess.
64
-
65
- - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list
66
- `netstat` itself reads. Each connection is an `xinpcb_n` (addresses and ports) followed by an
67
- `xsocket_n` that carries `so_uid` and `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and
68
- `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are paired by the pcb handle
69
- (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid equal
70
- to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state
71
- (`t_state`), so listeners and TIME_WAIT rows are told apart. The layout is not an API, so the
72
- first read in a process calibrates it: it opens a connected pair of its own (listen, connect,
73
- accept) and the listener and both ends of the pair must read back with their exact addresses,
74
- this process's uid and its pid. A layout that fails calibration is not used again in that
75
- process.
76
- - macOS 27.2 restricts both tables for an ad-hoc-signed interpreter (a Homebrew Python, measured on 2026-11-07 on a
77
- host agent's Mac): sysctl lists only this process's own sockets and netstat prints nothing. The calibration therefore
78
- also requires a child process's listener, and an empty netstat is `unknown` with reason `table-restricted`, never
79
- another account's (FAA-267). A caller that started the server itself (an installer and its launchd job) may vouch
80
- with that pid: `lsof -p PID` asks the process, which the restriction leaves readable, and `CheckedHTTPConnection`
81
- accepts its listener on the port as this account's (`vouch_pid`). Apple's own `/usr/bin/python3` sees the tables.
82
- - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then
83
- `ps -o uid= -p PID` for the owner of the one row a question needs. Measured: about 20 ms over
84
- 846 sockets, and it names the pids of root's listeners, which `lsof` run by an ordinary account
85
- cannot see. Another local service implemented the same check this way; it is kept as the
86
- documented fallback for a macOS whose record layout the calibration refuses.
87
- - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may
88
- read (also under `hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is
89
- only readable for this account's own processes, and the decision needs the uid alone.
90
- - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning
91
- pid; `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
92
- `GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process
93
- that cannot be opened (another account's, usually) is `unknown`, never `another-account`:
94
- the caller refuses either way, and the reason says which it was. `Owner.uid` is None there.
40
+ A caller that must not leak (a client about to send a bearer, a server on a multi-account host about to read a request)
41
+ treats everything but `this-account` as a refusal, `unknown` included: fail closed.
42
+
43
+ HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the answer is never cached across
44
+ connections; only `listener` is cached, for LISTENER_TTL. A TIME_WAIT row is a connection already closed and is never an
45
+ answer; two live rows with the same addresses are `unknown` (`several-rows`), never a guess.
46
+
47
+ - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list `netstat` itself reads.
48
+ Each connection is an `xinpcb_n` (addresses and ports) followed by an `xsocket_n` that carries `so_uid` and
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).
58
+ - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then `ps -o uid= -p PID` for
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.
61
+ - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may read (also under
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.
95
66
  - Anything else: `unknown`, reason `unsupported-platform`.
96
67
 
97
- `LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain
98
- sockets only, not for TCP, which is why a socket table is read here at all.
99
-
100
- WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a
101
- name, never sends anything, and never needs a privilege. It reports the process names
102
- `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.
103
71
  """
104
72
  from __future__ import annotations
105
73
 
106
74
  import http.client
107
75
  import ipaddress
76
+ import json
108
77
  import os
109
78
  import socket
110
79
  import struct
@@ -115,7 +84,7 @@ import time
115
84
  from typing import NamedTuple, Optional, Tuple
116
85
 
117
86
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
118
- PEER_OWNER_VERSION = "1.2.0+kit.5" # 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
119
88
 
120
89
  THIS_ACCOUNT = "this-account"
121
90
  ANOTHER_ACCOUNT = "another-account"
@@ -124,10 +93,8 @@ UNKNOWN = "unknown"
124
93
  NONE = "none"
125
94
  KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
126
95
 
127
- #: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
128
- #: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
129
- #: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
130
- #: (0.74.0 product reading 11, FAA-260).
96
+ #: The most table bytes one read accepts: 1 MB held about 1,200 sockets at `pcblist_n`'s 860 bytes each, which a busy
97
+ #: developer machine passes; 4 MiB holds about 6,800 at 616 bytes measured on macOS 26 (FAA-260).
131
98
  MAX_TABLE_BYTES = 4 * 1024 * 1024
132
99
  #: Every subprocess this module starts is stopped after this many seconds.
133
100
  TIMEOUT_SECONDS = 2.0
@@ -141,6 +108,8 @@ LSOF = "/usr/sbin/lsof"
141
108
  #: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
142
109
  #: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
143
110
  TABLE_RESTRICTED = "table-restricted"
111
+ #: A macOS table that lists sockets but not the reader's own connection (DEC-0155 RT-1, FAA-312).
112
+ NO_ROW_RESTRICTED = TABLE_RESTRICTED + ": no row for this connection"
144
113
 
145
114
  #: Client codes of `own_connection` (ListenerRefused.code).
146
115
  SERVER_NOT_OWN = "server-not-own"
@@ -167,8 +136,7 @@ class Owner(_OwnerFields):
167
136
 
168
137
 
169
138
  class Row(_RowFields):
170
- """One TCP socket of the host's table. `uid` is None where the table carries no owner
171
- (netstat, Windows): it is resolved from `pid` only for the row a question selects."""
139
+ """One TCP socket of the host's table; `uid` is None where it carries none (netstat, Windows): read from `pid`."""
172
140
  __slots__ = ()
173
141
 
174
142
 
@@ -188,9 +156,8 @@ class ListenerRefused(Exception):
188
156
  # -- addresses --------------------------------------------------------------------------------------------------------
189
157
 
190
158
  def norm_host(host: str) -> str:
191
- """One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the
192
- wildcard, `localhost` is 127.0.0.1. Anything unparseable is returned unchanged (and matches
193
- only itself)."""
159
+ """One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the wildcard, `localhost` is
160
+ 127.0.0.1. Anything unparseable is returned unchanged (and matches only itself)."""
194
161
  if host in ("*", ""):
195
162
  return "*"
196
163
  if host == "localhost":
@@ -215,12 +182,16 @@ def _address(addr) -> tuple[str, int]:
215
182
  # -- classification ---------------------------------------------------------------------------------------------------
216
183
 
217
184
  def _posix_uid() -> Optional[int]:
218
- """This process's uid, or None where the OS has none (Windows compares SIDs instead). The one
219
- place this module reads it: this file is vendored without its project's OS layer, so the uid
220
- is read here, once, on the POSIX paths only (an engine boundary test names this function)."""
185
+ """This process's uid, or None where the OS has none (Windows compares SIDs). The one place this module reads it:
186
+ vendored without its project's OS layer, it reads it here, on POSIX only (an engine boundary test names it)."""
221
187
  return os.getuid() if hasattr(os, "getuid") else None
222
188
 
223
189
 
190
+ def _set_uid() -> bool:
191
+ """Real and effective uid differ: such a reader uses none of DEC-0155's proofs (RT-2 to RT-5), the vouch included."""
192
+ return hasattr(os, "geteuid") and os.geteuid() != _posix_uid()
193
+
194
+
224
195
  def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Optional[int] = None) -> Owner:
225
196
  """`this-account` for this process's uid, `system` for uid 0 or pid 1, else `another-account`. A client that runs
226
197
  as root itself reads root's listener as its own account's (the kit's order; the vendored file put uid 0 first)."""
@@ -310,12 +281,11 @@ def parse_ps_uid(text: str) -> Optional[int]:
310
281
 
311
282
 
312
283
  def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
313
- """`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each
314
- 32-bit word of an address in host byte order and each port in network order, in hex. A
315
- connected row with inode 0 has no socket yet (a connection the server has not accept()ed,
316
- or one already closed): the kernel prints uid 0 for it, so its uid is not read (None);
317
- `_owner_of` reads a server end not yet accepted as its listener's and a closed client as
318
- unknown (the kit's addition, 0.74.0 unit readings 3 and 4)."""
284
+ """`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each 32-bit word of an address
285
+ in host byte order and each port in network order, in hex. A connected row with inode 0 has no socket yet (a
286
+ connection the server has not accept()ed, or one already closed): the kernel prints uid 0 for it, so its uid is not
287
+ read (None); `_owner_of` reads a server end not yet accepted as its listener's and a closed client as unknown (the
288
+ kit's addition, 0.74.0 unit readings 3 and 4)."""
319
289
  order = byteorder or sys.byteorder
320
290
  rows: list[Row] = []
321
291
  for line in text.splitlines()[1:]:
@@ -540,10 +510,9 @@ def _child_line(child: "subprocess.Popen[bytes]", deadline: float) -> Optional[b
540
510
 
541
511
 
542
512
  def _child_listener_seen(rows_of) -> Optional[bool]:
543
- """Whether the table shows a listener of a child process of this one: False when it does not (the table shows
544
- this process alone, FAA-267), None when no child could be started (a frozen or embedded host has no interpreter to
545
- start), or it gave no port line within TIMEOUT_SECONDS (silence, a partial or non-numeric line, an early exit, a
546
- 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."""
547
516
  if not sys.executable or getattr(sys, "frozen", False):
548
517
  return None
549
518
  try:
@@ -624,7 +593,12 @@ def read_table() -> tuple[list[Row], str]:
624
593
  except (TableUnreadable, struct.error, ValueError):
625
594
  how = "netstat" # this read only; calibration stands
626
595
  if how == "netstat":
627
- return parse_netstat(_run([NETSTAT, "-anv", "-p", "tcp"])), how
596
+ try:
597
+ text = _run([NETSTAT, "-anv", "-p", "tcp"])
598
+ except TableUnreadable as exc:
599
+ # A netstat that fails, exits non-zero or times out is a restricted table (DEC-0155 RT-1, FAA-312).
600
+ raise TableUnreadable(f"{TABLE_RESTRICTED}: {exc}") from None
601
+ return parse_netstat(text), how
628
602
  if how == "proc":
629
603
  rows: list[Row] = []
630
604
  for name, family in (("/proc/net/tcp", socket.AF_INET), ("/proc/net/tcp6", socket.AF_INET6)):
@@ -779,6 +753,18 @@ def _windows_owner(pid: Optional[int], how: str) -> Owner: # pragma: no cover -
779
753
 
780
754
  # -- the questions ----------------------------------------------------------------------------------------------------
781
755
 
756
+ def restricted(rows: list[Row], how: str, *, own_uid: Optional[int] = None, own_pid: Optional[int] = None) -> bool:
757
+ """DEC-0155 RT-1 (FAA-312): a macOS table holding no row of another uid (sysctl, which carries uids) or of another
758
+ process (netstat, which carries pids) is restricted, and a missing row proves nothing. Never on Linux or Windows."""
759
+ if how == "sysctl":
760
+ own = _posix_uid() if own_uid is None else own_uid
761
+ return not any(row.uid is not None and row.uid != own for row in rows)
762
+ if how == "netstat":
763
+ own = os.getpid() if own_pid is None else own_pid
764
+ return not any(row.pid is not None and row.pid != own for row in rows)
765
+ return False
766
+
767
+
782
768
  def find(rows: list[Row], local: tuple[str, int], remote: tuple[str, int]) -> list[Row]:
783
769
  """The live connected rows with exactly this local and remote address: one, or the caller
784
770
  cannot tell. A TIME_WAIT row is a connection already closed and is left out. A row printed twice is one
@@ -799,19 +785,14 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
799
785
  if how != "proc" or len(found) == 1:
800
786
  break
801
787
  # A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
802
- if not found:
803
- return Owner(UNKNOWN, None, None, how, "no-row")
788
+ if not found: # a macOS table without the reader's own connection is restricted (DEC-0155 RT-1)
789
+ return Owner(UNKNOWN, None, None, how, NO_ROW_RESTRICTED if how in ("sysctl", "netstat") else "no-row")
804
790
  if len(found) > 1:
805
791
  return Owner(UNKNOWN, None, None, how, "several-rows")
806
792
  if how == "proc" and server and found[0].uid is None:
807
- # Linux: a connection the server has not accept()ed yet is printed with uid 0 and inode 0; it belongs to the
808
- # listener on its local address (the exact address, else the wildcard), and dies with it
809
- # (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
810
- # process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
811
- # accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
812
- # CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
813
- # A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
814
- # nothing sent to it reaches anyone (0.74.0 unit reading 6).
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.
815
796
  return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
816
797
  if how == "proc" and found[0].uid is None:
817
798
  # A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
@@ -840,11 +821,32 @@ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) ->
840
821
  return Owner(UNKNOWN, None, None, how, "several-rows")
841
822
 
842
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
+
843
845
  def peer_at(server: tuple, client: tuple) -> Owner:
844
- """The owner of the CLIENT socket of the connection between `server` and `client`, as a
845
- server sees them (its own address, the peer's address): the row whose local address is the
846
- client's and whose remote address is the server's."""
847
- return _owner_of(_address(client), _address(server))
846
+ """The owner of the CLIENT socket of the connection between `server` and `client`, as a server sees them (its own
847
+ address, the peer's): the row whose local address is the client's and whose remote address is the server's."""
848
+ client, server = _address(client), _address(server)
849
+ return _proved("accepted", client, server, wait=False, fallback=_owner_of(client, server))
848
850
 
849
851
 
850
852
  def peer(sock: socket.socket) -> Owner:
@@ -857,16 +859,15 @@ def peer(sock: socket.socket) -> Owner:
857
859
 
858
860
 
859
861
  def server_end(sock: socket.socket) -> Owner:
860
- """For a client's connected socket: who accepted it. The row whose local address is the
861
- server's and whose remote address is this socket's own, which is the server process's
862
- accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
863
- On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
864
- answers (the contract's CT-254, HA-2's Linux source)."""
862
+ """For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
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)
@@ -887,9 +888,8 @@ def pick_listener(rows: list[Row], port: int) -> list[Row]:
887
888
 
888
889
 
889
890
  def listener(port: int) -> Owner:
890
- """Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections.
891
- `none` when nothing does, `unknown` (`several-rows`) when sockets of two owners do. Cached for
892
- LISTENER_TTL seconds."""
891
+ """Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections. `none` when nothing does,
892
+ `unknown` (`several-rows`) when sockets of two owners do. Cached for LISTENER_TTL seconds."""
893
893
  now = time.monotonic()
894
894
  hit = _LISTENERS.get(port)
895
895
  if hit is not None and now - hit[0] < LISTENER_TTL:
@@ -899,11 +899,19 @@ def listener(port: int) -> Owner:
899
899
  except TableUnreadable as exc:
900
900
  owner = Owner(UNKNOWN, None, None, method(), str(exc))
901
901
  else:
902
- owner = _one_owner(pick_listener(rows, port), how)
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)
903
904
  _LISTENERS[port] = (now, owner)
904
905
  return owner
905
906
 
906
907
 
908
+ def _restricted_listener(rows: list[Row], port: int, how: str) -> Owner:
909
+ """DEC-0155 RT-4 (FAA-312): under a restricted table only this account's own bind on exactly 127.0.0.1 is its own."""
910
+ owner = _one_owner([row for row in rows if row.listening and row.local == ("127.0.0.1", port)], how)
911
+ reason = f"{TABLE_RESTRICTED}: no listener of this account on exactly 127.0.0.1:{port}"
912
+ return owner if owner.kind == THIS_ACCOUNT else Owner(UNKNOWN, None, None, how, reason)
913
+
914
+
907
915
  def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Optional[tuple[str, str]]:
908
916
  """(code, why) when a client must not send on a connection whose server end is `owner`."""
909
917
  if owner.kind in (ANOTHER_ACCOUNT, SYSTEM):
@@ -916,29 +924,30 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
916
924
  return None
917
925
 
918
926
 
919
- def pid_listens(pid: int, port: int) -> bool:
920
- """macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on
921
- 127.0.0.1:<port> or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection).
922
- `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that started `pid`
923
- itself (a launchd job it installed) may vouch with it."""
924
- if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
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()):
925
936
  return False
926
937
  try:
927
- uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
928
- if uid is None or uid != _posix_uid():
929
- 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
930
942
  out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
931
943
  except TableUnreadable:
932
944
  return False
933
- return any(host in ("127.0.0.1", "*") and p == port for host, p in parse_lsof_listeners(out))
945
+ return any(host == "127.0.0.1" and p == port for host, p in parse_lsof_listeners(out))
934
946
 
935
947
 
936
948
  class CheckedHTTPConnection(http.client.HTTPConnection):
937
- """An `http.client.HTTPConnection` whose every `connect()` checks the server end before
938
- returning. `http.client` connects again by itself (`auto_open`) when the server answered
939
- `Connection: close` or the caller closed the connection, and that new connection may reach a
940
- different listener; so the check is in `connect()`, not once in `own_connection`. A refused
941
- 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."""
942
951
 
943
952
  def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
944
953
  vouch_pid: Optional[int] = None) -> None:
@@ -953,7 +962,7 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
953
962
  super().connect()
954
963
  owner = server_end(self.sock)
955
964
  if (owner.kind == UNKNOWN and (owner.reason or "").startswith(TABLE_RESTRICTED) and getattr(self, "vouch_pid", None)
956
- 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)):
957
966
  # The table hides other processes (FAA-267); the process this caller started holds the listener.
958
967
  owner = Owner(THIS_ACCOUNT, _posix_uid(), self.vouch_pid, "lsof-pid")
959
968
  refused = refusal(owner, self.port, self.expect_pid)
@@ -964,19 +973,28 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
964
973
 
965
974
  def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
966
975
  host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
967
- """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
968
- (and, with `expect_pid`, that process's). The check runs on the very connection the caller
969
- then sends on, so no listener can change between the check and the request, and it runs
970
- again on every reconnect `http.client` makes. No proxy is consulted. Raises ListenerRefused
971
- after closing the connection:
972
-
973
- server-not-own another account's or the system's process accepted it
974
- server-unverified its owner could not be told (nothing is sent: fail closed)
975
- server-not-this-workspace this account's process, but not the pid expected
976
-
977
- `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
978
- (FAA-267).
979
- """
976
+ """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's (and, with `expect_pid`,
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
+ `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started (FAA-267)."""
980
981
  conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
981
982
  conn.connect()
982
983
  return conn
984
+
985
+
986
+ def main(argv: Optional[list] = None) -> int:
987
+ """`listener <port> [--json]`: the kind of who listens on 127.0.0.1:<port>, which the dashboard link gate takes as
988
+ `listener_owner` (FAA-315), or the whole owner as JSON; exit 2 on a usage error."""
989
+ args = list(sys.argv[1:] if argv is None else argv)
990
+ as_json = args[2:] == ["--json"]
991
+ if len(args) != 2 + as_json or args[0] != "listener" or not _ascii_digits(args[1]) or not 0 < int(args[1]) < 65536:
992
+ print("usage: fabric_peer_owner.py listener <port> [--json]", file=sys.stderr)
993
+ return 2
994
+ owner = listener(int(args[1]))
995
+ print(json.dumps(owner.as_dict()) if as_json else owner.kind)
996
+ return 0
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ sys.exit(main())