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

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