@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +169 -3
  2. package/README.md +7 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +1 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +38 -12
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +4 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +2 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +3 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +28 -12
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +13 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +11 -7
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +275 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +1 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +5 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +1 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +15 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +55 -11
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +47 -31
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +1 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +25 -7
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +27 -5
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +1 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +1 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +4 -2
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +19 -12
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +1 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -1
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +22 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +4 -5
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +7 -2
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +1 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +21 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +6 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +1 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +1 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +16 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +1 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +6 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +25 -3
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +456 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +1 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +18 -2
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +1 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +19 -5
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +56 -19
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -0
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +16 -2
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +181 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +20 -15
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +469 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +1 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +1 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +1 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +3 -1
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +405 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +1 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +1 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +17 -3
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +98 -9
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +37 -48
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +1 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +25 -8
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +1 -0
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +28 -6
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +1 -0
  108. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +1 -0
  109. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +19 -13
  110. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +24 -14
  111. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -0
  112. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +1 -0
  113. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -1
  114. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +32 -7
  115. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +19 -16
  116. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +10 -5
  117. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +1 -0
  118. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +56 -96
  119. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +99 -1
  120. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +1 -0
  121. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +1 -0
  122. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +16 -6
  123. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +1 -0
  124. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +1 -0
  125. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +44 -1
  126. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +648 -0
  127. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +17 -4
  128. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +1 -0
  129. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +1 -0
  130. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -5
  131. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +108 -108
  132. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -0
  133. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -0
  134. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -0
  135. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +38 -12
  136. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  137. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +1 -0
  138. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +391 -0
  139. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +19 -16
  140. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +1 -0
  141. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +493 -0
  142. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +1 -0
  143. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -0
  144. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +632 -104
  145. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  146. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +17 -7
  147. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  148. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -5
  149. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  150. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  151. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  152. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  153. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  154. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -3
  155. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +182 -0
  156. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  157. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  158. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  159. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  160. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  161. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  162. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  163. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  164. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  165. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
3
+ """A person's identity at every door of a service published through `tailscale serve` (FAA-324). Standard library
4
+ only; the Node twin is fabric-tailnet-identity.mjs, and both answer test/fixtures/tailnet-identity/vectors.json alike.
5
+
6
+ `tailscale serve` proxies HTTPS on the node's tailnet name to the service's loopback listener and sets the caller's
7
+ `Tailscale-User-Login` (and `Tailscale-User-Name`, read here for display only). What Tailscale documents
8
+ (https://tailscale.com/kb/1312/serve, "Identity headers", read 2026-11-12) and its source shows
9
+ (tailscale/tailscale `ipn/ipnlocal/serve.go`, `addTailscaleIdentityHeaders` and `delHeaderVariants`):
10
+
11
+ - Serve removes any incoming `Tailscale-User-Login`, `Tailscale-User-Name`, `Tailscale-User-Profile-Pic`,
12
+ `Tailscale-Funnel-Request` and `Tailscale-Headers-Info` (also their `_` spellings) before it sets its own, so a
13
+ tailnet client cannot forge them through serve.
14
+ - It sets no identity header for a tagged node, for Funnel traffic (it sets `Tailscale-Funnel-Request: ?1` instead)
15
+ or for traffic it cannot attribute; it always sets `X-Forwarded-For` to the source address.
16
+ - Its documentation advises the backend to listen on localhost only: a backend reachable directly lets any caller
17
+ supply its own values.
18
+
19
+ So the header is believed only when the service listens on a loopback address and the request's peer is a loopback
20
+ address (it came through the local serve proxy). A service bound to another address, or a request from another
21
+ peer, is refused: there the header comes from the network and is forgeable. What this does not stop: another
22
+ process of this machine can send the header to the loopback port. The kit's `LoopbackHTTPServer` refuses another OS
23
+ account's peer (DEC-0154, FAA-227); a process of the service's own account is trusted as the service is. Where that
24
+ matters, give serve a listener of its own (`fabric_tailnet`, FAA-297).
25
+
26
+ No request is let through by default. A request without a login (a tagged node's, a Funnel request's, a direct
27
+ call's), with an empty, repeated or malformed one, or from a login no rule opens the door for, is refused with a
28
+ stable code and a sentence, and every refusal is HTTP 403. What a login may open comes from a groups file the
29
+ organization writes (`tailnet-groups/1`, its JSON Schema `tailnet-groups.schema.json` beside this file):
30
+
31
+ {"schema": "tailnet-groups/1",
32
+ "groups": {"ops": ["ana@example.com"]},
33
+ "rules": [{"group": "ops", "door": "dashboard", "capabilities": "*"}]}
34
+
35
+ The agents base's `door-groups/1` file is read as well, for one service (`service=`, the service's id): its
36
+ groups name `people` and, per service, the doors and tools they open (`*` every door, `dashboard`, `mcp` every MCP
37
+ tool, or a tool name, opened at the MCP door and at the service's own door); a retired group opens nothing, and
38
+ without a service every door stays closed.
39
+
40
+ Logins are compared as ASCII email-style names lowercased whole (local part and domain alike), after trimming the
41
+ spaces and tabs HTTP allows around a header value; nothing else is changed (no dot or `+tag` folding, no IDN
42
+ mapping), and a login that is not plain ASCII (serve RFC 2047-encodes a non-ASCII one) is refused as malformed.
43
+ `TailnetIdentity` reads the file again when its inode, size, mtime or ctime change (and, while its mtime is under two
44
+ seconds old, when its bytes do: a same-size rewrite within one timestamp tick), without a restart, and while it
45
+ is unreadable or invalid every door refuses (`groups-unreadable`, `groups-invalid`); the reason is logged once per
46
+ change, naming the field, never the file's content."""
47
+
48
+ from __future__ import annotations
49
+
50
+ import errno
51
+ import hashlib
52
+ import ipaddress
53
+ import json
54
+ import os
55
+ import re
56
+ import stat
57
+ import sys
58
+ import threading
59
+ import time
60
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union
61
+
62
+ GROUPS_SCHEMA = "tailnet-groups/1"
63
+ DOOR_GROUPS = "door-groups/1" # the agents base's format (thread d23eeb7a), read for one service
64
+ DOORS = ("dashboard", "mcp", "service")
65
+ LOGIN_HEADER = "tailscale-user-login"
66
+ NAME_HEADER = "tailscale-user-name"
67
+ FUNNEL_HEADER = "tailscale-funnel-request"
68
+ #: The largest groups file read; a larger one is invalid.
69
+ MAX_GROUPS_BYTES = 1048576
70
+ # A file whose stat is unchanged but whose mtime is this close to now may have been rewritten within one timestamp tick
71
+ # (same size, coarse clock): its content is read and compared (git's racy-clean rule).
72
+ RACY_NS = 2_000_000_000
73
+
74
+ #: The patterns as the JSON Schema writes them (ECMAScript); compiled below to end at the end of the string.
75
+ LOGIN_PATTERN = "^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$"
76
+ NAME_PATTERN = "^[a-z][a-z0-9._:/-]{0,127}$"
77
+ _LOGIN = re.compile(LOGIN_PATTERN[:-1] + r"\Z", re.ASCII)
78
+ _NAME = re.compile(NAME_PATTERN[:-1] + r"\Z", re.ASCII)
79
+ _TAILNET = (ipaddress.ip_network("100.64.0.0/10"), ipaddress.ip_network("fd7a:115c:a1e0::/48"))
80
+ _ENCODED_WORD = re.compile(r"=\?utf-8\?q\?([^?]*)\?=\Z", re.ASCII | re.IGNORECASE)
81
+
82
+ #: code -> HTTP status: every refusal is 403.
83
+ REFUSALS: Dict[str, int] = {code: 403 for code in (
84
+ "bind-not-loopback", "not-loopback", "groups-unreadable", "groups-invalid", "funnel-request", "no-login",
85
+ "tagged-node", "login-ambiguous", "login-malformed", "unknown-login", "not-allowed")}
86
+
87
+ #: code -> the sentence a person reads; the twins carry the same text.
88
+ REASONS: Dict[str, str] = {
89
+ "ok": "Allowed by a rule of a group this login belongs to.",
90
+ "bind-not-loopback": "The service does not listen on a loopback address only, so a Tailscale identity header "
91
+ "could come from the network and is not believed.",
92
+ "not-loopback": "The request did not come from the local tailscale serve proxy (its peer is not a loopback "
93
+ "address), so its Tailscale identity header is not believed.",
94
+ "groups-unreadable": "The tailnet groups file cannot be read, so every door stays closed until it can.",
95
+ "groups-invalid": "The tailnet groups file is not a valid tailnet-groups/1 or door-groups/1 document, so every door stays closed "
96
+ "until it is fixed.",
97
+ "funnel-request": "The request came through Tailscale Funnel from the public internet, which carries no tailnet "
98
+ "identity.",
99
+ "no-login": "The request carries no Tailscale-User-Login header, so the caller is not known.",
100
+ "tagged-node": "The request came through tailscale serve from a tailnet node with no user (a tagged node), which "
101
+ "carries no login.",
102
+ "login-ambiguous": "The request carries more than one Tailscale-User-Login header.",
103
+ "login-malformed": "The Tailscale-User-Login header is not a login name.",
104
+ "unknown-login": "This login is in no group of the tailnet groups file.",
105
+ "not-allowed": "No group of this login has a rule that opens this door for this capability.",
106
+ }
107
+
108
+ Headers = Union[Mapping[str, Any], Iterable[Tuple[str, Any]], Any]
109
+
110
+
111
+ class GroupsError(ValueError):
112
+ """A groups file that cannot be used. `code` is `groups-invalid` or `groups-unreadable`; `where` names the field
113
+ (or the reason the file could not be read), never a value from the file."""
114
+
115
+ def __init__(self, code: str, where: str = ""):
116
+ self.code = code
117
+ self.where = where
118
+ super().__init__("%s%s" % (code, " at " + where if where else ""))
119
+
120
+
121
+ def _invalid(where: str) -> GroupsError:
122
+ return GroupsError("groups-invalid", where)
123
+
124
+
125
+ def load_groups(doc: Any, service: Optional[str] = None) -> Dict[str, Any]:
126
+ """Check a parsed `tailnet-groups/1` or `door-groups/1` document; answer it normalized (logins lowercased and
127
+ sorted, each group's members once, the rules) or raise GroupsError `groups-invalid` naming the field. A
128
+ `door-groups/1` file is read for one `service` (its id): without one every door stays closed."""
129
+ if not isinstance(doc, dict):
130
+ raise _invalid("document")
131
+ if "format" in doc and "schema" not in doc:
132
+ if doc["format"] != DOOR_GROUPS:
133
+ raise _invalid("format")
134
+ return _load_door_groups(doc, service)
135
+ if doc.get("schema") != GROUPS_SCHEMA:
136
+ raise _invalid("schema")
137
+ if set(doc) - {"schema", "about", "groups", "rules"}:
138
+ raise _invalid("document")
139
+ if "about" in doc and (not isinstance(doc["about"], str) or len(doc["about"]) > 1000):
140
+ raise _invalid("about")
141
+ groups = doc.get("groups")
142
+ if not isinstance(groups, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in groups):
143
+ raise _invalid("groups")
144
+ out: Dict[str, List[str]] = {}
145
+ for name in sorted(groups):
146
+ members = groups[name]
147
+ if not isinstance(members, list) or not all(isinstance(m, str) and _LOGIN.match(m) for m in members):
148
+ raise _invalid("groups.%s" % name)
149
+ out[name] = sorted({m.lower() for m in members})
150
+ rules = doc.get("rules")
151
+ if not isinstance(rules, list):
152
+ raise _invalid("rules")
153
+ norm: List[Dict[str, Any]] = []
154
+ for i, rule in enumerate(rules):
155
+ where = "rules[%d]" % i
156
+ if not isinstance(rule, dict) or set(rule) != {"group", "door", "capabilities"}:
157
+ raise _invalid(where)
158
+ if not isinstance(rule["group"], str) or rule["group"] not in out:
159
+ raise _invalid(where + ".group")
160
+ if rule["door"] not in DOORS:
161
+ raise _invalid(where + ".door")
162
+ caps = rule["capabilities"]
163
+ if caps != "*" and (not isinstance(caps, list) or not caps
164
+ or not all(isinstance(c, str) and _NAME.match(c) for c in caps)):
165
+ raise _invalid(where + ".capabilities")
166
+ norm.append({"group": rule["group"], "door": rule["door"],
167
+ "capabilities": "*" if caps == "*" else sorted(set(caps))})
168
+ return {"schema": GROUPS_SCHEMA, "groups": out, "rules": norm}
169
+
170
+
171
+ def _values(headers: Headers, name: str) -> List[str]:
172
+ """Every value of one header, case-insensitively, from a parsed message (`get_all`), a mapping whose values may
173
+ be lists, or pairs."""
174
+ if hasattr(headers, "get_all") and hasattr(headers, "items"):
175
+ found: List[Any] = list(headers.get_all(name) or [])
176
+ else:
177
+ pairs = headers.items() if isinstance(headers, Mapping) else headers
178
+ found = []
179
+ for key, value in pairs:
180
+ if isinstance(key, str) and key.lower() == name:
181
+ found.extend(value if isinstance(value, (list, tuple)) else [value])
182
+ return [v if isinstance(v, str) else "" for v in found]
183
+
184
+
185
+ def _address(text: Any) -> Optional[Any]:
186
+ if not isinstance(text, str):
187
+ return None
188
+ try:
189
+ ip = ipaddress.ip_address(text.split("%", 1)[0])
190
+ except ValueError:
191
+ return None
192
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
193
+ return ip.ipv4_mapped
194
+ return ip
195
+
196
+
197
+ def _is_loopback(text: Any) -> bool:
198
+ ip = _address(text)
199
+ return ip is not None and ip.is_loopback
200
+
201
+
202
+ def _from_tailnet(values: List[str]) -> bool:
203
+ """One X-Forwarded-For value that is a Tailscale address (100.64.0.0/10, fd7a:115c:a1e0::/48): serve set it."""
204
+ if len(values) != 1:
205
+ return False
206
+ ip = _address(values[0].strip(" \t"))
207
+ return ip is not None and any(ip.version == net.version and ip in net for net in _TAILNET)
208
+
209
+
210
+ def _display_name(values: List[str]) -> Optional[str]:
211
+ """`Tailscale-User-Name` for display: ASCII as sent, or serve's RFC 2047 `=?utf-8?q?...?=` words decoded; None
212
+ when absent, repeated, over 256 characters or holding a control character."""
213
+ if len(values) != 1:
214
+ return None
215
+ text = values[0].strip(" \t")
216
+ if any(ord(c) > 126 for c in text):
217
+ return None
218
+ if text.startswith("=?"):
219
+ raw = b""
220
+ for word in text.split(" "):
221
+ match = _ENCODED_WORD.match(word)
222
+ if match is None:
223
+ return None
224
+ encoded, i = match.group(1), 0
225
+ while i < len(encoded):
226
+ if encoded[i] == "=":
227
+ pair = encoded[i + 1:i + 3]
228
+ if len(pair) != 2 or not all(c in "0123456789abcdefABCDEF" for c in pair):
229
+ return None
230
+ raw += bytes([int(pair, 16)])
231
+ i += 3
232
+ else:
233
+ raw += b" " if encoded[i] == "_" else encoded[i].encode("ascii")
234
+ i += 1
235
+ try:
236
+ text = raw.decode("utf-8")
237
+ except UnicodeDecodeError:
238
+ return None
239
+ if not text or len(text) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in text):
240
+ return None
241
+ return text
242
+
243
+
244
+ def _load_door_groups(doc: Dict[str, Any], service: Optional[str]) -> Dict[str, Any]:
245
+ """`door-groups/1` (the agents base): {format, version, generated_at?, groups: {name: {people, doors: {service:
246
+ [capability]}}}, retired?: {name: {at, why}}}. For `service`: `*` opens every door, `dashboard` the dashboard,
247
+ `mcp` every MCP tool, any other name that capability at the MCP door and at the service's own door; a group with
248
+ no entry for the service, and a retired group, open nothing."""
249
+ if not isinstance(service, str) or not service:
250
+ raise _invalid("service")
251
+ if set(doc) - {"format", "version", "generated_at", "groups", "retired"}:
252
+ raise _invalid("document")
253
+ version = doc.get("version")
254
+ if isinstance(version, float) and version.is_integer(): # JSON's 3.0 is JavaScript's 3, as the Node twin reads it
255
+ version = int(version)
256
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
257
+ raise _invalid("version")
258
+ if doc.get("generated_at") is not None and (not isinstance(doc["generated_at"], str) or len(doc["generated_at"]) > 64):
259
+ raise _invalid("generated_at")
260
+ groups = doc.get("groups")
261
+ if not isinstance(groups, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in groups):
262
+ raise _invalid("groups")
263
+ retired = doc.get("retired", {})
264
+ if not isinstance(retired, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in retired):
265
+ raise _invalid("retired")
266
+ for name in sorted(retired):
267
+ entry = retired[name]
268
+ if name in groups:
269
+ raise _invalid("retired.%s" % name)
270
+ if not isinstance(entry, dict) or set(entry) - {"at", "why"} or not all(
271
+ v is None or (isinstance(v, str) and len(v) <= 1000) for v in entry.values()):
272
+ raise _invalid("retired.%s" % name)
273
+ people: Dict[str, List[str]] = {}
274
+ rules: List[Dict[str, Any]] = []
275
+ for name in sorted(groups):
276
+ group = groups[name]
277
+ if not isinstance(group, dict) or set(group) != {"people", "doors"}:
278
+ raise _invalid("groups.%s" % name)
279
+ members = group["people"]
280
+ if not isinstance(members, list) or not all(isinstance(m, str) and _LOGIN.match(m) for m in members):
281
+ raise _invalid("groups.%s.people" % name)
282
+ doors = group["doors"]
283
+ if not isinstance(doors, dict) or not all(isinstance(k, str) and k for k in doors):
284
+ raise _invalid("groups.%s.doors" % name)
285
+ for key in sorted(doors):
286
+ caps = doors[key]
287
+ if not isinstance(caps, list) or not caps or not all(
288
+ isinstance(c, str) and (c == "*" or _NAME.match(c)) for c in caps):
289
+ raise _invalid("groups.%s.doors.%s" % (name, key))
290
+ people[name] = sorted({m.lower() for m in members})
291
+ caps = set(doors.get(service) or [])
292
+ tools = sorted(caps - {"*", "dashboard", "mcp"})
293
+ opened = {"dashboard": "*" if caps & {"*", "dashboard"} else None,
294
+ "mcp": "*" if caps & {"*", "mcp"} else (tools or None),
295
+ "service": "*" if "*" in caps else (tools or None)}
296
+ rules.extend({"group": name, "door": door, "capabilities": opened[door]} for door in DOORS if opened[door])
297
+ return {"schema": DOOR_GROUPS, "groups": people, "rules": rules}
298
+
299
+
300
+ def _decision(code: str, door: str, capability: str, login: Optional[str] = None, name: Optional[str] = None,
301
+ groups: Optional[List[str]] = None) -> Dict[str, Any]:
302
+ return {"allowed": code == "ok", "status": 200 if code == "ok" else REFUSALS[code], "code": code,
303
+ "reason": REASONS[code], "login": login, "name": name, "groups": list(groups or []), "door": door,
304
+ "capability": capability}
305
+
306
+
307
+ def decide(*, groups: Union[Mapping[str, Any], GroupsError], bound: Any, peer: Any, headers: Headers, door: str,
308
+ capability: str) -> Dict[str, Any]:
309
+ """Decide one request. `groups` is what load_groups answered, or the GroupsError that keeps every door closed;
310
+ `bound` the address the service listens on; `peer` the connection's remote address; `door` one of DOORS;
311
+ `capability` the name the service asks for (a page, a tool, an action). Answers {allowed, status, code, reason,
312
+ login, name, groups, door, capability}: 200 `ok`, else 403 with the refusal's code. Never raises for a request;
313
+ a door or capability outside the rules' grammar is the service's mistake and raises ValueError."""
314
+ if door not in DOORS:
315
+ raise ValueError("door is one of %s" % ", ".join(DOORS))
316
+ if not isinstance(capability, str) or not _NAME.match(capability):
317
+ raise ValueError("capability is a name matching %s" % NAME_PATTERN)
318
+ if not _is_loopback(bound):
319
+ return _decision("bind-not-loopback", door, capability)
320
+ if not _is_loopback(peer):
321
+ return _decision("not-loopback", door, capability)
322
+ if isinstance(groups, GroupsError):
323
+ return _decision(groups.code if groups.code in REFUSALS else "groups-invalid", door, capability)
324
+ if _values(headers, FUNNEL_HEADER):
325
+ return _decision("funnel-request", door, capability)
326
+ logins = [v.strip(" \t") for v in _values(headers, LOGIN_HEADER)]
327
+ logins = [v for v in logins if v]
328
+ if not logins:
329
+ code = "tagged-node" if _from_tailnet(_values(headers, "x-forwarded-for")) else "no-login"
330
+ return _decision(code, door, capability)
331
+ if len(logins) > 1:
332
+ return _decision("login-ambiguous", door, capability)
333
+ if not _LOGIN.match(logins[0]):
334
+ return _decision("login-malformed", door, capability)
335
+ login = logins[0].lower()
336
+ name = _display_name(_values(headers, NAME_HEADER))
337
+ member_of = sorted(g for g, members in groups["groups"].items() if login in members)
338
+ if not member_of:
339
+ return _decision("unknown-login", door, capability, login, name)
340
+ for rule in groups["rules"]:
341
+ if rule["group"] in member_of and rule["door"] == door and (
342
+ rule["capabilities"] == "*" or capability in rule["capabilities"]):
343
+ return _decision("ok", door, capability, login, name, member_of)
344
+ return _decision("not-allowed", door, capability, login, name, member_of)
345
+
346
+
347
+ def refusal_body(decision: Mapping[str, Any]) -> Dict[str, Any]:
348
+ """The JSON body of a refusal: {"error": {"code", "message"}}; never a header value or a login."""
349
+ if decision.get("allowed") or decision.get("code") not in REFUSALS:
350
+ raise ValueError("refusal_body takes a refused decision")
351
+ return {"error": {"code": decision["code"], "message": REASONS[decision["code"]]}}
352
+
353
+
354
+ def send_refusal(handler: Any, decision: Mapping[str, Any]) -> None:
355
+ """Answer a refused decision on an http.server handler: 403, JSON, no-store."""
356
+ body = json.dumps(refusal_body(decision)).encode("utf-8")
357
+ handler.send_response(REFUSALS[decision["code"]])
358
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
359
+ handler.send_header("Content-Length", str(len(body)))
360
+ handler.send_header("Cache-Control", "no-store")
361
+ handler.send_header("X-Content-Type-Options", "nosniff")
362
+ handler.end_headers()
363
+ handler.wfile.write(body)
364
+
365
+
366
+ def _errno_name(exc: OSError) -> str:
367
+ """The error's symbolic name (ENOENT, EACCES), as Node names it; never its message, which may hold the path."""
368
+ return errno.errorcode.get(exc.errno or 0, "unknown")
369
+
370
+
371
+ def _stderr(line: str) -> None:
372
+ sys.stderr.write(line + "\n")
373
+
374
+
375
+ class TailnetIdentity:
376
+ """The groups file at `path`, read again when it changes, and the decision at a door. `recheck_seconds` 0 looks
377
+ at the file on every request (one stat); a positive value at most that often. `log` takes one line per change of
378
+ the file's state (default stderr); `clock` gives monotonic seconds (a test replaces it)."""
379
+
380
+ def __init__(self, path: Union[str, "os.PathLike[str]"], *, recheck_seconds: float = 0.0,
381
+ log: Optional[Callable[[str], None]] = None, clock: Callable[[], float] = time.monotonic,
382
+ service: Optional[str] = None) -> None:
383
+ if not isinstance(recheck_seconds, (int, float)) or recheck_seconds < 0:
384
+ raise ValueError("recheck_seconds is a number of seconds, 0 or more")
385
+ self.path = os.fspath(path)
386
+ self.service = service # the service's id: the entry a door-groups/1 file is read for
387
+ self.recheck_seconds = float(recheck_seconds)
388
+ self._log = log or _stderr
389
+ self._clock = clock
390
+ self._lock = threading.Lock()
391
+ self._signature: Any = object() # nothing read yet
392
+ self._digest: Optional[str] = None # sha256 of the bytes last read
393
+ self._groups: Union[Dict[str, Any], GroupsError] = GroupsError("groups-unreadable", "not read")
394
+ self._checked: Optional[float] = None
395
+ self._logged: Any = None
396
+
397
+ def _stat(self) -> Tuple[Any, ...]:
398
+ try:
399
+ st = os.stat(self.path)
400
+ except OSError as exc:
401
+ return ("error", _errno_name(exc))
402
+ if not stat.S_ISREG(st.st_mode):
403
+ return ("error", "not-a-file")
404
+ return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
405
+
406
+ def _read(self) -> Union[Dict[str, Any], GroupsError]:
407
+ try:
408
+ with open(self.path, "rb") as handle:
409
+ data = handle.read(MAX_GROUPS_BYTES + 1)
410
+ except OSError as exc:
411
+ self._digest = None
412
+ return GroupsError("groups-unreadable", _errno_name(exc))
413
+ self._digest = hashlib.sha256(data).hexdigest()
414
+ if len(data) > MAX_GROUPS_BYTES:
415
+ return _invalid("size")
416
+ try:
417
+ text = data.decode("utf-8")
418
+ except UnicodeDecodeError:
419
+ return _invalid("encoding")
420
+
421
+ def no_constant(_name: str) -> Any:
422
+ raise ValueError("not JSON")
423
+
424
+ try:
425
+ doc = json.loads(text, parse_constant=no_constant)
426
+ except ValueError:
427
+ return _invalid("json")
428
+ try:
429
+ return load_groups(doc, self.service)
430
+ except GroupsError as exc:
431
+ return exc
432
+
433
+ def current(self) -> Union[Dict[str, Any], GroupsError]:
434
+ """The groups in force: the normalized file, or the GroupsError that keeps every door closed."""
435
+ with self._lock:
436
+ now = self._clock()
437
+ if self._checked is not None and self.recheck_seconds and now - self._checked < self.recheck_seconds:
438
+ return self._groups
439
+ self._checked = now
440
+ signature = self._stat()
441
+ if signature == self._signature and signature[0] != "error" and self._rewritten_in_place(signature):
442
+ self._signature = object()
443
+ if signature != self._signature:
444
+ self._signature = signature
445
+ self._groups = (GroupsError("groups-unreadable", signature[1])
446
+ if signature[0] == "error" else self._read())
447
+ self._report(signature)
448
+ return self._groups
449
+
450
+ def _rewritten_in_place(self, signature: Tuple[Any, ...]) -> bool:
451
+ """Whether a file whose stat did not change holds other bytes: asked only while its mtime is within RACY_NS of
452
+ now, since a rewrite of the same size within one timestamp tick leaves every stat field as it was."""
453
+ if abs(time.time_ns() - signature[3]) >= RACY_NS:
454
+ return False
455
+ try:
456
+ with open(self.path, "rb") as handle:
457
+ data = handle.read(MAX_GROUPS_BYTES + 1)
458
+ except OSError:
459
+ return True # read it again the usual way, which names the error
460
+ return hashlib.sha256(data).hexdigest() != self._digest
461
+
462
+ def _report(self, signature: Any) -> None:
463
+ state = self._groups
464
+ if isinstance(state, GroupsError):
465
+ key = (signature, state.code, state.where)
466
+ line = ("fabric: tailnet groups file %s: %s (%s); every door refuses until it is fixed"
467
+ % (self.path, state.code, state.where))
468
+ else:
469
+ key = (signature, "ok")
470
+ line = ("fabric: tailnet groups file %s loaded: %d groups, %d rules"
471
+ % (self.path, len(state["groups"]), len(state["rules"])))
472
+ if key != self._logged:
473
+ self._logged = key
474
+ try:
475
+ self._log(line)
476
+ except Exception: # a log that fails never opens a door
477
+ pass
478
+
479
+ def decide(self, *, bound: Any, peer: Any, headers: Headers, door: str, capability: str) -> Dict[str, Any]:
480
+ """decide() with the groups in force."""
481
+ return decide(groups=self.current(), bound=bound, peer=peer, headers=headers, door=door,
482
+ capability=capability)
483
+
484
+ def decide_request(self, handler: Any, *, door: str, capability: str, bound: Any = None) -> Dict[str, Any]:
485
+ """decide() for an http.server handler: the address the server listens on (`bound` overrides it), the
486
+ connection's peer and the request's headers. A bound address nobody can tell is refused."""
487
+ if bound is None:
488
+ address = getattr(getattr(handler, "server", None), "server_address", None)
489
+ bound = address[0] if isinstance(address, tuple) and address else None
490
+ client = getattr(handler, "client_address", None)
491
+ peer = client[0] if isinstance(client, tuple) and client else None
492
+ return self.decide(bound=bound, peer=peer, headers=getattr(handler, "headers", None) or [], door=door,
493
+ capability=capability)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Dates and times the way the contract reads them, once for the whole kit (FAA-65, layer L0).
2
3
 
3
4
  - ``parse_rfc3339_ms(text)``: strict RFC 3339 (a ``T``, an offset, a day that exists; no space, no leap second) as
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """What a service spends on models (DEC-0021, DEC-0065, DEC-0091): usage receipts, the bounded ledger, the usage
2
3
  report and its rules (FAC-SEM-025), OpenRouter attribution (FAC-SEM-098) and every spending limit (DEC-0027).
3
4
  Part of the service kit (FAA-65, layer L3)."""