@passioncode-ai/fabric-agent-adapter 0.77.0 → 0.79.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 (74) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +31 -11
  3. package/SECURITY.md +17 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +15 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +27 -39
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +74 -3
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +339 -26
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +367 -27
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +323 -25
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -6
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  62. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  63. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  64. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  65. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  66. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  67. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +30 -39
  68. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  69. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). Standard
3
+ library only; the Node twin is fabric-tailnet.mjs, and both answer test/fixtures/tailnet/vectors.json alike.
4
+
5
+ The agent listens on loopback and `tailscale serve` proxies the tailnet to that listener, adding the caller's
6
+ `Tailscale-User-Login`. The header proves nothing by itself: any process on the machine can send it to a loopback port.
7
+ So the agent gives serve a listener of its own (a loopback port, or a Unix socket, only serve's backend), and the
8
+ header is believed only on that listener and only from a loopback peer. On any other listener (the Fabric one, whose
9
+ callers carry the session key) a request that carries the header is refused, never half-trusted.
10
+
11
+ What a person may open comes from a perimeter file (`tailnet-perimeter/1`) generated from the organization's access
12
+ registry, never kept by hand: it names its source (`generatedFrom`), and a perimeter without one is refused. Each
13
+ group lists its members' logins, the dashboards and the capabilities they may reach. MCP takes, besides the identity,
14
+ a personal key: only its SHA-256 is stored, it belongs to one login, and its capabilities may not be wider than the
15
+ groups of that login. Every refusal is 401 or 403 with a stable `code`; no answer echoes a key or a header value
16
+ other than the login itself."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import ipaddress
23
+ import re
24
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
25
+
26
+ PERIMETER_SCHEMA = "tailnet-perimeter/1"
27
+ IDENTITY_HEADER = "tailscale-user-login"
28
+ #: Headers tailscale serve sets for a person; on a listener that is not serve's they mark a forged or misrouted call.
29
+ SERVE_HEADERS = ("tailscale-user-login", "tailscale-user-name", "tailscale-user-profile-pic")
30
+ LISTENER_SERVE = "serve"
31
+ SURFACES = ("dashboard", "mcp")
32
+
33
+ _LOGIN = re.compile(r"^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}\Z", re.ASCII)
34
+ _NAME = re.compile(r"^[a-z][a-z0-9._:/-]{0,127}\Z", re.ASCII)
35
+ _KEY_HASH = re.compile(r"^sha256:[0-9a-f]{64}\Z", re.ASCII)
36
+ _KEY_ID = re.compile(r"^[A-Za-z0-9._-]{1,64}\Z", re.ASCII)
37
+
38
+ #: code -> HTTP status. 401: the caller has not proven who it is; 403: it has, and may not.
39
+ REFUSALS: Dict[str, int] = {
40
+ "serve-header-off-serve": 403,
41
+ "peer-not-loopback": 403,
42
+ "identity-missing": 401,
43
+ "identity-ambiguous": 401,
44
+ "identity-malformed": 401,
45
+ "not-in-perimeter": 403,
46
+ "dashboard-not-granted": 403,
47
+ "capability-not-granted": 403,
48
+ "key-required": 401,
49
+ "key-unknown": 401,
50
+ "key-not-yours": 403,
51
+ "key-wider-than-group": 403,
52
+ }
53
+
54
+ Headers = Union[Mapping[str, Any], Iterable[Tuple[str, Any]]]
55
+
56
+
57
+ class PerimeterError(ValueError):
58
+ """A perimeter or key list that cannot be used. `code` names why; `where` names the place, never a value."""
59
+
60
+ def __init__(self, code: str, where: str = ""):
61
+ self.code = code
62
+ self.where = where
63
+ super().__init__("%s%s" % (code, " at " + where if where else ""))
64
+
65
+
66
+ def hash_key(value: str) -> str:
67
+ """The stored form of a personal key: `sha256:<hex>` of its UTF-8 bytes."""
68
+ if not isinstance(value, str) or not value:
69
+ raise ValueError("a key is a non-empty string")
70
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
71
+
72
+
73
+ def _names(value: Any, where: str) -> List[str]:
74
+ if not isinstance(value, list) or not all(isinstance(v, str) and _NAME.match(v) for v in value):
75
+ raise PerimeterError("perimeter-invalid", where)
76
+ return sorted(set(value))
77
+
78
+
79
+ def load_perimeter(doc: Any) -> Dict[str, Any]:
80
+ """Check a parsed `tailnet-perimeter/1` document and answer its normalized form; raise PerimeterError otherwise."""
81
+ if not isinstance(doc, dict) or doc.get("schema") != PERIMETER_SCHEMA:
82
+ raise PerimeterError("perimeter-schema", "schema")
83
+ source = doc.get("generatedFrom")
84
+ if not isinstance(source, dict) or not all(isinstance(source.get(k), str) and source.get(k)
85
+ for k in ("registry", "revision", "at")):
86
+ raise PerimeterError("perimeter-not-generated", "generatedFrom")
87
+ groups = doc.get("groups")
88
+ if not isinstance(groups, dict) or not groups:
89
+ raise PerimeterError("perimeter-invalid", "groups")
90
+ out: Dict[str, Dict[str, List[str]]] = {}
91
+ for name in sorted(groups):
92
+ group = groups[name]
93
+ where = "groups.%s" % name if _NAME.match(name) else "groups"
94
+ if not _NAME.match(name) or not isinstance(group, dict):
95
+ raise PerimeterError("perimeter-invalid", where)
96
+ members = group.get("members")
97
+ if not isinstance(members, list) or not all(isinstance(m, str) and _LOGIN.match(m) for m in members):
98
+ raise PerimeterError("perimeter-invalid", where + ".members")
99
+ out[name] = {"members": sorted({m.lower() for m in members}),
100
+ "dashboards": _names(group.get("dashboards", []), where + ".dashboards"),
101
+ "capabilities": _names(group.get("capabilities", []), where + ".capabilities")}
102
+ return {"schema": PERIMETER_SCHEMA, "generatedFrom": {k: source[k] for k in ("registry", "revision", "at")},
103
+ "groups": out}
104
+
105
+
106
+ def load_keys(keys: Any) -> List[Dict[str, Any]]:
107
+ """Check a list of personal keys `{id, login, sha256, capabilities}` (the hash only, never the key)."""
108
+ if not isinstance(keys, list):
109
+ raise PerimeterError("keys-invalid", "keys")
110
+ out, seen = [], set()
111
+ for i, key in enumerate(keys):
112
+ where = "keys[%d]" % i
113
+ if (not isinstance(key, dict) or not isinstance(key.get("id"), str) or not _KEY_ID.match(key["id"])
114
+ or not isinstance(key.get("login"), str) or not _LOGIN.match(key["login"])
115
+ or not isinstance(key.get("sha256"), str) or not _KEY_HASH.match(key["sha256"])):
116
+ raise PerimeterError("keys-invalid", where)
117
+ if key["id"] in seen or key["sha256"] in {k["sha256"] for k in out}:
118
+ raise PerimeterError("keys-duplicate", where)
119
+ seen.add(key["id"])
120
+ out.append({"id": key["id"], "login": key["login"].lower(), "sha256": key["sha256"],
121
+ "capabilities": _names(key.get("capabilities", []), where + ".capabilities")})
122
+ return out
123
+
124
+
125
+ def _header_values(headers: Headers, name: str) -> List[str]:
126
+ pairs = headers.items() if isinstance(headers, Mapping) else headers
127
+ found: List[str] = []
128
+ for key, value in pairs:
129
+ if isinstance(key, str) and key.lower() == name:
130
+ found.extend(value if isinstance(value, (list, tuple)) else [value])
131
+ return [v if isinstance(v, str) else "" for v in found]
132
+
133
+
134
+ def _is_loopback(address: Optional[str]) -> bool:
135
+ if address == "unix": # a Unix socket only serve can reach (the agent sets its mode)
136
+ return True
137
+ if not isinstance(address, str):
138
+ return False
139
+ try:
140
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
141
+ except ValueError:
142
+ return False
143
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
144
+ ip = ip.ipv4_mapped
145
+ return ip.is_loopback
146
+
147
+
148
+ def _refuse(code: str, login: Optional[str] = None) -> Dict[str, Any]:
149
+ return {"allowed": False, "status": REFUSALS[code], "code": code, "login": login, "groups": []}
150
+
151
+
152
+ def _bearer(headers: Headers) -> Optional[str]:
153
+ values = _header_values(headers, "authorization")
154
+ if len(values) != 1:
155
+ return None
156
+ scheme, _, rest = values[0].strip().partition(" ")
157
+ rest = rest.strip()
158
+ return rest if scheme.lower() == "bearer" and rest and " " not in rest else None
159
+
160
+
161
+ def authorize(*, listener: str, peer: Optional[str], headers: Headers, perimeter: Mapping[str, Any], surface: str,
162
+ target: str, keys: Optional[Sequence[Mapping[str, Any]]] = None) -> Dict[str, Any]:
163
+ """Decide one request. `listener` is which of the agent's listeners took it (`serve` for serve's own); `peer` the
164
+ connecting address (`unix` for a Unix socket); `perimeter` and `keys` as load_perimeter and load_keys answer;
165
+ `surface` `dashboard` or `mcp`; `target` the dashboard's or the capability's name. Answers
166
+ {allowed, status, code, login, groups}: status 200 with code `ok`, else 401 or 403 with the refusal's code."""
167
+ if surface not in SURFACES:
168
+ raise ValueError("surface is one of %s" % ", ".join(SURFACES))
169
+ if listener != LISTENER_SERVE:
170
+ if any(_header_values(headers, h) for h in SERVE_HEADERS):
171
+ return _refuse("serve-header-off-serve")
172
+ raise ValueError("authorize decides serve's listener; the Fabric listener keeps its session key")
173
+ if not _is_loopback(peer):
174
+ return _refuse("peer-not-loopback")
175
+ logins = _header_values(headers, IDENTITY_HEADER)
176
+ if not logins:
177
+ return _refuse("identity-missing")
178
+ if len(logins) > 1:
179
+ return _refuse("identity-ambiguous")
180
+ login = logins[0].strip()
181
+ if not _LOGIN.match(login):
182
+ return _refuse("identity-malformed")
183
+ login = login.lower()
184
+ groups = sorted(name for name, g in perimeter["groups"].items() if login in g["members"])
185
+ if not groups:
186
+ return _refuse("not-in-perimeter", login)
187
+ reach = {"dashboard": set(), "mcp": set()}
188
+ for name in groups:
189
+ reach["dashboard"].update(perimeter["groups"][name]["dashboards"])
190
+ reach["mcp"].update(perimeter["groups"][name]["capabilities"])
191
+ if surface == "dashboard":
192
+ if target not in reach["dashboard"]:
193
+ return dict(_refuse("dashboard-not-granted", login), groups=groups)
194
+ return {"allowed": True, "status": 200, "code": "ok", "login": login, "groups": groups}
195
+ presented = _bearer(headers)
196
+ if presented is None:
197
+ return dict(_refuse("key-required", login), groups=groups)
198
+ digest = hash_key(presented)
199
+ key = next((k for k in keys or () if hmac.compare_digest(k["sha256"], digest)), None)
200
+ if key is None:
201
+ return dict(_refuse("key-unknown", login), groups=groups)
202
+ if key["login"] != login:
203
+ return dict(_refuse("key-not-yours", login), groups=groups)
204
+ if not set(key["capabilities"]) <= reach["mcp"]:
205
+ return dict(_refuse("key-wider-than-group", login), groups=groups)
206
+ if target not in key["capabilities"]:
207
+ return dict(_refuse("capability-not-granted", login), groups=groups)
208
+ return {"allowed": True, "status": 200, "code": "ok", "login": login, "groups": groups, "key": key["id"]}