@passioncode-ai/fabric-agent-adapter 0.82.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.
- package/CHANGELOG.md +122 -3
- package/README.md +6 -3
- package/SECURITY.md +4 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// A person's identity at every door of a service published through `tailscale serve` (FAA-324). The twin of
|
|
2
|
+
// fabric_tailnet_identity.py; both answer test/fixtures/tailnet-identity/vectors.json alike. No dependencies beyond
|
|
3
|
+
// node:fs, node:net and node:perf_hooks.
|
|
4
|
+
//
|
|
5
|
+
// Serve sets the caller's `Tailscale-User-Login` (and `Tailscale-User-Name`, read here for display only) and removes
|
|
6
|
+
// any copies a client sent, also their `_` spellings; it sets none for a tagged node, for Funnel traffic (it sets
|
|
7
|
+
// `Tailscale-Funnel-Request: ?1` instead) or for traffic it cannot attribute, and it always sets `X-Forwarded-For`
|
|
8
|
+
// (https://tailscale.com/kb/1312/serve, "Identity headers", read 2026-11-12; tailscale/tailscale
|
|
9
|
+
// ipn/ipnlocal/serve.go, addTailscaleIdentityHeaders). The header is believed only when the service listens on a
|
|
10
|
+
// loopback address and the request's peer is a loopback address (it came through the local serve proxy); anything
|
|
11
|
+
// else is refused, since there the header comes from the network. Another process of this machine can still send it
|
|
12
|
+
// to the loopback port: the kit's loopbackServer refuses another OS account's peer (DEC-0154, FAA-227), and serve's
|
|
13
|
+
// own listener (fabric-tailnet.mjs, FAA-297) closes the rest.
|
|
14
|
+
//
|
|
15
|
+
// What a login may open comes from a `tailnet-groups/1` file the organization writes (its JSON Schema is
|
|
16
|
+
// tailnet-groups.schema.json beside this file). Logins are compared as ASCII names lowercased whole, after trimming
|
|
17
|
+
// the spaces and tabs HTTP allows; a non-ASCII (RFC 2047-encoded) login is refused as malformed. TailnetIdentity reads
|
|
18
|
+
// the file again when its inode, size, mtime or ctime change, and while it is unreadable or invalid every door refuses;
|
|
19
|
+
// the reason is logged once per change, naming the field, never the file's content. Every refusal is 403 with a code.
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
23
|
+
import { BlockList, isIP } from 'node:net';
|
|
24
|
+
import { performance } from 'node:perf_hooks';
|
|
25
|
+
|
|
26
|
+
export const GROUPS_SCHEMA = 'tailnet-groups/1';
|
|
27
|
+
export const DOOR_GROUPS = 'door-groups/1'; // the agents base's format (thread d23eeb7a), read for one service
|
|
28
|
+
export const DOORS = Object.freeze(['dashboard', 'mcp', 'service']);
|
|
29
|
+
export const LOGIN_HEADER = 'tailscale-user-login';
|
|
30
|
+
export const NAME_HEADER = 'tailscale-user-name';
|
|
31
|
+
export const FUNNEL_HEADER = 'tailscale-funnel-request';
|
|
32
|
+
/** The largest groups file read; a larger one is invalid. */
|
|
33
|
+
export const MAX_GROUPS_BYTES = 1048576;
|
|
34
|
+
|
|
35
|
+
/** The patterns as the JSON Schema writes them. */
|
|
36
|
+
export const LOGIN_PATTERN = '^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$';
|
|
37
|
+
export const NAME_PATTERN = '^[a-z][a-z0-9._:/-]{0,127}$';
|
|
38
|
+
const LOGIN = new RegExp(LOGIN_PATTERN);
|
|
39
|
+
const NAME = new RegExp(NAME_PATTERN);
|
|
40
|
+
const ENCODED_WORD = /^=\?utf-8\?q\?([^?]*)\?=$/i;
|
|
41
|
+
|
|
42
|
+
const LOOPBACK = new BlockList();
|
|
43
|
+
LOOPBACK.addSubnet('127.0.0.0', 8, 'ipv4');
|
|
44
|
+
LOOPBACK.addAddress('::1', 'ipv6');
|
|
45
|
+
const TAILNET = new BlockList();
|
|
46
|
+
TAILNET.addSubnet('100.64.0.0', 10, 'ipv4');
|
|
47
|
+
TAILNET.addSubnet('fd7a:115c:a1e0::', 48, 'ipv6');
|
|
48
|
+
|
|
49
|
+
/** code -> HTTP status: every refusal is 403. */
|
|
50
|
+
export const REFUSALS = Object.freeze(Object.fromEntries([
|
|
51
|
+
'bind-not-loopback', 'not-loopback', 'groups-unreadable', 'groups-invalid', 'funnel-request', 'no-login',
|
|
52
|
+
'tagged-node', 'login-ambiguous', 'login-malformed', 'unknown-login', 'not-allowed'].map((c) => [c, 403])));
|
|
53
|
+
|
|
54
|
+
/** code -> the sentence a person reads; the twins carry the same text. */
|
|
55
|
+
export const REASONS = Object.freeze({
|
|
56
|
+
ok: 'Allowed by a rule of a group this login belongs to.',
|
|
57
|
+
'bind-not-loopback': 'The service does not listen on a loopback address only, so a Tailscale identity header '
|
|
58
|
+
+ 'could come from the network and is not believed.',
|
|
59
|
+
'not-loopback': 'The request did not come from the local tailscale serve proxy (its peer is not a loopback '
|
|
60
|
+
+ 'address), so its Tailscale identity header is not believed.',
|
|
61
|
+
'groups-unreadable': 'The tailnet groups file cannot be read, so every door stays closed until it can.',
|
|
62
|
+
'groups-invalid': 'The tailnet groups file is not a valid tailnet-groups/1 or door-groups/1 document, so every door stays closed '
|
|
63
|
+
+ 'until it is fixed.',
|
|
64
|
+
'funnel-request': 'The request came through Tailscale Funnel from the public internet, which carries no tailnet '
|
|
65
|
+
+ 'identity.',
|
|
66
|
+
'no-login': 'The request carries no Tailscale-User-Login header, so the caller is not known.',
|
|
67
|
+
'tagged-node': 'The request came through tailscale serve from a tailnet node with no user (a tagged node), which '
|
|
68
|
+
+ 'carries no login.',
|
|
69
|
+
'login-ambiguous': 'The request carries more than one Tailscale-User-Login header.',
|
|
70
|
+
'login-malformed': 'The Tailscale-User-Login header is not a login name.',
|
|
71
|
+
'unknown-login': 'This login is in no group of the tailnet groups file.',
|
|
72
|
+
'not-allowed': 'No group of this login has a rule that opens this door for this capability.',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** A groups file that cannot be used: `code` groups-invalid or groups-unreadable, `where` the field (or why it could
|
|
76
|
+
* not be read), never a value from the file. */
|
|
77
|
+
export class GroupsError extends Error {
|
|
78
|
+
constructor(code, where = '') {
|
|
79
|
+
super(`${code}${where ? ` at ${where}` : ''}`);
|
|
80
|
+
this.name = 'GroupsError';
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.where = where;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const invalid = (where) => new GroupsError('groups-invalid', where);
|
|
87
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
88
|
+
const sortedUnique = (items) => [...new Set(items)].sort();
|
|
89
|
+
|
|
90
|
+
/** Check a parsed `tailnet-groups/1` document; answer it normalized or throw GroupsError groups-invalid. */
|
|
91
|
+
export function loadGroups(doc, { service } = {}) {
|
|
92
|
+
if (!isPlainObject(doc)) throw invalid('document');
|
|
93
|
+
if (Object.hasOwn(doc, 'format') && !Object.hasOwn(doc, 'schema')) {
|
|
94
|
+
if (doc.format !== DOOR_GROUPS) throw invalid('format');
|
|
95
|
+
return loadDoorGroups(doc, service);
|
|
96
|
+
}
|
|
97
|
+
if (doc.schema !== GROUPS_SCHEMA) throw invalid('schema');
|
|
98
|
+
if (Object.keys(doc).some((k) => !['schema', 'about', 'groups', 'rules'].includes(k))) throw invalid('document');
|
|
99
|
+
if ('about' in doc && (typeof doc.about !== 'string' || [...doc.about].length > 1000)) throw invalid('about');
|
|
100
|
+
const { groups } = doc;
|
|
101
|
+
if (!isPlainObject(groups) || !Object.keys(groups).every((n) => NAME.test(n))) throw invalid('groups');
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const name of Object.keys(groups).sort()) {
|
|
104
|
+
const members = groups[name];
|
|
105
|
+
if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
|
|
106
|
+
throw invalid(`groups.${name}`);
|
|
107
|
+
}
|
|
108
|
+
out[name] = sortedUnique(members.map((m) => m.toLowerCase()));
|
|
109
|
+
}
|
|
110
|
+
const { rules } = doc;
|
|
111
|
+
if (!Array.isArray(rules)) throw invalid('rules');
|
|
112
|
+
const norm = rules.map((rule, i) => {
|
|
113
|
+
const where = `rules[${i}]`;
|
|
114
|
+
const keys = isPlainObject(rule) ? Object.keys(rule).sort() : [];
|
|
115
|
+
if (keys.join(',') !== 'capabilities,door,group') throw invalid(where);
|
|
116
|
+
if (typeof rule.group !== 'string' || !Object.hasOwn(out, rule.group)) throw invalid(`${where}.group`);
|
|
117
|
+
if (!DOORS.includes(rule.door)) throw invalid(`${where}.door`);
|
|
118
|
+
const caps = rule.capabilities;
|
|
119
|
+
if (caps !== '*' && (!Array.isArray(caps) || !caps.length
|
|
120
|
+
|| !caps.every((c) => typeof c === 'string' && NAME.test(c)))) throw invalid(`${where}.capabilities`);
|
|
121
|
+
return { group: rule.group, door: rule.door, capabilities: caps === '*' ? '*' : sortedUnique(caps) };
|
|
122
|
+
});
|
|
123
|
+
return { schema: GROUPS_SCHEMA, groups: out, rules: norm };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `door-groups/1` (the agents base): {format, version, generated_at?, groups: {name: {people, doors: {service:
|
|
127
|
+
* [capability]}}}, retired?: {name: {at, why}}}. For `service`: `*` opens every door, `dashboard` the dashboard, `mcp`
|
|
128
|
+
* every MCP tool, any other name that capability at the MCP door and at the service's own door; a group with no entry
|
|
129
|
+
* for the service, and a retired group, open nothing. Without a service every door stays closed. */
|
|
130
|
+
function loadDoorGroups(doc, service) {
|
|
131
|
+
if (typeof service !== 'string' || !service) throw invalid('service');
|
|
132
|
+
if (Object.keys(doc).some((k) => !['format', 'version', 'generated_at', 'groups', 'retired'].includes(k))) {
|
|
133
|
+
throw invalid('document');
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isInteger(doc.version) || doc.version < 1) throw invalid('version');
|
|
136
|
+
if (doc.generated_at != null && (typeof doc.generated_at !== 'string' || [...doc.generated_at].length > 64)) {
|
|
137
|
+
throw invalid('generated_at');
|
|
138
|
+
}
|
|
139
|
+
const { groups } = doc;
|
|
140
|
+
if (!isPlainObject(groups) || !Object.keys(groups).every((n) => NAME.test(n))) throw invalid('groups');
|
|
141
|
+
const retired = Object.hasOwn(doc, 'retired') ? doc.retired : {};
|
|
142
|
+
if (!isPlainObject(retired) || !Object.keys(retired).every((n) => NAME.test(n))) throw invalid('retired');
|
|
143
|
+
for (const name of Object.keys(retired).sort()) {
|
|
144
|
+
const entry = retired[name];
|
|
145
|
+
if (Object.hasOwn(groups, name)) throw invalid(`retired.${name}`);
|
|
146
|
+
if (!isPlainObject(entry) || Object.keys(entry).some((k) => k !== 'at' && k !== 'why')
|
|
147
|
+
|| !Object.values(entry).every((v) => v === null || (typeof v === 'string' && [...v].length <= 1000))) {
|
|
148
|
+
throw invalid(`retired.${name}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const people = {};
|
|
152
|
+
const rules = [];
|
|
153
|
+
for (const name of Object.keys(groups).sort()) {
|
|
154
|
+
const group = groups[name];
|
|
155
|
+
if (!isPlainObject(group) || Object.keys(group).sort().join(',') !== 'doors,people') throw invalid(`groups.${name}`);
|
|
156
|
+
const members = group.people;
|
|
157
|
+
if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
|
|
158
|
+
throw invalid(`groups.${name}.people`);
|
|
159
|
+
}
|
|
160
|
+
const { doors } = group;
|
|
161
|
+
if (!isPlainObject(doors) || !Object.keys(doors).every((k) => k.length > 0)) throw invalid(`groups.${name}.doors`);
|
|
162
|
+
for (const key of Object.keys(doors).sort()) {
|
|
163
|
+
const caps = doors[key];
|
|
164
|
+
if (!Array.isArray(caps) || !caps.length || !caps.every((c) => typeof c === 'string' && (c === '*' || NAME.test(c)))) {
|
|
165
|
+
throw invalid(`groups.${name}.doors.${key}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
people[name] = sortedUnique(members.map((m) => m.toLowerCase()));
|
|
169
|
+
const caps = new Set(Object.hasOwn(doors, service) ? doors[service] : []);
|
|
170
|
+
const tools = sortedUnique([...caps].filter((c) => !['*', 'dashboard', 'mcp'].includes(c)));
|
|
171
|
+
const opened = {
|
|
172
|
+
dashboard: caps.has('*') || caps.has('dashboard') ? '*' : null,
|
|
173
|
+
mcp: caps.has('*') || caps.has('mcp') ? '*' : (tools.length ? tools : null),
|
|
174
|
+
service: caps.has('*') ? '*' : (tools.length ? tools : null),
|
|
175
|
+
};
|
|
176
|
+
for (const door of DOORS) if (opened[door]) rules.push({ group: name, door, capabilities: opened[door] });
|
|
177
|
+
}
|
|
178
|
+
return { schema: DOOR_GROUPS, groups: people, rules };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every value of one header, case-insensitively: a web Headers, an object whose values may be arrays (Node's
|
|
182
|
+
* headersDistinct), or [name, value] pairs. */
|
|
183
|
+
function values(headers, name) {
|
|
184
|
+
const found = [];
|
|
185
|
+
const pairs = headers == null ? []
|
|
186
|
+
: typeof headers.entries === 'function' && !Array.isArray(headers) ? headers.entries()
|
|
187
|
+
: Array.isArray(headers) ? headers : Object.entries(headers);
|
|
188
|
+
for (const [key, value] of pairs) {
|
|
189
|
+
if (typeof key === 'string' && key.toLowerCase() === name) found.push(...(Array.isArray(value) ? value : [value]));
|
|
190
|
+
}
|
|
191
|
+
return found.map((v) => (typeof v === 'string' ? v : ''));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function address(text) {
|
|
195
|
+
if (typeof text !== 'string') return null;
|
|
196
|
+
let host = text.split('%')[0];
|
|
197
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(host);
|
|
198
|
+
if (mapped) host = mapped[1];
|
|
199
|
+
const family = isIP(host);
|
|
200
|
+
if (!family) return null;
|
|
201
|
+
if (family === 6) {
|
|
202
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
203
|
+
if (hex) {
|
|
204
|
+
const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
205
|
+
return { host: `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`, type: 'ipv4' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { host, type: family === 4 ? 'ipv4' : 'ipv6' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const isLoopback = (text) => {
|
|
212
|
+
const a = address(text);
|
|
213
|
+
return a !== null && LOOPBACK.check(a.host, a.type);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function fromTailnet(vals) {
|
|
217
|
+
if (vals.length !== 1) return false;
|
|
218
|
+
const a = address(vals[0].replace(/^[ \t]+|[ \t]+$/g, ''));
|
|
219
|
+
return a !== null && TAILNET.check(a.host, a.type);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const trim = (s) => s.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
223
|
+
|
|
224
|
+
/** `Tailscale-User-Name` for display: ASCII as sent, or serve's RFC 2047 words decoded; null when absent, repeated,
|
|
225
|
+
* over 256 characters or holding a control character. */
|
|
226
|
+
function displayName(vals) {
|
|
227
|
+
if (vals.length !== 1) return null;
|
|
228
|
+
let text = trim(vals[0]);
|
|
229
|
+
if ([...text].some((c) => c.codePointAt(0) > 126)) return null;
|
|
230
|
+
if (text.startsWith('=?')) {
|
|
231
|
+
const bytes = [];
|
|
232
|
+
for (const word of text.split(' ')) {
|
|
233
|
+
const m = ENCODED_WORD.exec(word);
|
|
234
|
+
if (!m) return null;
|
|
235
|
+
const enc = m[1];
|
|
236
|
+
for (let i = 0; i < enc.length;) {
|
|
237
|
+
if (enc[i] === '=') {
|
|
238
|
+
const pair = enc.slice(i + 1, i + 3);
|
|
239
|
+
if (!/^[0-9a-fA-F]{2}$/.test(pair)) return null;
|
|
240
|
+
bytes.push(parseInt(pair, 16));
|
|
241
|
+
i += 3;
|
|
242
|
+
} else {
|
|
243
|
+
bytes.push(enc[i] === '_' ? 32 : enc.charCodeAt(i));
|
|
244
|
+
i += 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes));
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const chars = [...text];
|
|
255
|
+
if (!chars.length || chars.length > 256 || chars.some((c) => c.codePointAt(0) < 32 || c.codePointAt(0) === 127)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return text;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function decision(code, door, capability, login = null, name = null, groups = []) {
|
|
262
|
+
return { allowed: code === 'ok', status: code === 'ok' ? 200 : REFUSALS[code], code, reason: REASONS[code], login,
|
|
263
|
+
name, groups: [...groups], door, capability };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Decide one request. `groups` is what loadGroups answered or the GroupsError that keeps every door closed;
|
|
267
|
+
* `bound` the address the service listens on; `peer` the connection's remote address; `door` one of DOORS;
|
|
268
|
+
* `capability` the name the service asks for. Answers {allowed, status, code, reason, login, name, groups, door,
|
|
269
|
+
* capability}. A door or capability outside the rules' grammar is the service's mistake and throws TypeError. */
|
|
270
|
+
export function decide({ groups, bound, peer, headers, door, capability }) {
|
|
271
|
+
if (!DOORS.includes(door)) throw new TypeError(`door is one of ${DOORS.join(', ')}`);
|
|
272
|
+
if (typeof capability !== 'string' || !NAME.test(capability)) {
|
|
273
|
+
throw new TypeError(`capability is a name matching ${NAME_PATTERN}`);
|
|
274
|
+
}
|
|
275
|
+
if (!isLoopback(bound)) return decision('bind-not-loopback', door, capability);
|
|
276
|
+
if (!isLoopback(peer)) return decision('not-loopback', door, capability);
|
|
277
|
+
if (groups instanceof GroupsError) {
|
|
278
|
+
return decision(Object.hasOwn(REFUSALS, groups.code) ? groups.code : 'groups-invalid', door, capability);
|
|
279
|
+
}
|
|
280
|
+
if (values(headers, FUNNEL_HEADER).length) return decision('funnel-request', door, capability);
|
|
281
|
+
const logins = values(headers, LOGIN_HEADER).map(trim).filter((v) => v);
|
|
282
|
+
if (!logins.length) {
|
|
283
|
+
return decision(fromTailnet(values(headers, 'x-forwarded-for')) ? 'tagged-node' : 'no-login', door, capability);
|
|
284
|
+
}
|
|
285
|
+
if (logins.length > 1) return decision('login-ambiguous', door, capability);
|
|
286
|
+
if (!LOGIN.test(logins[0])) return decision('login-malformed', door, capability);
|
|
287
|
+
const login = logins[0].toLowerCase();
|
|
288
|
+
const name = displayName(values(headers, NAME_HEADER));
|
|
289
|
+
const memberOf = Object.keys(groups.groups).filter((g) => groups.groups[g].includes(login)).sort();
|
|
290
|
+
if (!memberOf.length) return decision('unknown-login', door, capability, login, name);
|
|
291
|
+
const opened = groups.rules.some((r) => memberOf.includes(r.group) && r.door === door
|
|
292
|
+
&& (r.capabilities === '*' || r.capabilities.includes(capability)));
|
|
293
|
+
return decision(opened ? 'ok' : 'not-allowed', door, capability, login, name, memberOf);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The JSON body of a refusal: {error: {code, message}}; never a header value or a login. */
|
|
297
|
+
export function refusalBody(d) {
|
|
298
|
+
if (!d || d.allowed || !Object.hasOwn(REFUSALS, d.code)) throw new TypeError('refusalBody takes a refused decision');
|
|
299
|
+
return { error: { code: d.code, message: REASONS[d.code] } };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Answer a refused decision on a node:http response: 403, JSON, no-store. */
|
|
303
|
+
export function sendRefusal(res, d) {
|
|
304
|
+
const body = JSON.stringify(refusalBody(d));
|
|
305
|
+
res.writeHead(REFUSALS[d.code], { 'content-type': 'application/json; charset=utf-8',
|
|
306
|
+
'content-length': Buffer.byteLength(body), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
307
|
+
res.end(body);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readBounded(path) {
|
|
311
|
+
const fd = openSync(path, 'r');
|
|
312
|
+
try {
|
|
313
|
+
if (!fstatSync(fd).isFile()) {
|
|
314
|
+
const err = new Error('not a file');
|
|
315
|
+
err.code = 'EISDIR';
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const buf = Buffer.alloc(MAX_GROUPS_BYTES + 1);
|
|
319
|
+
let size = 0;
|
|
320
|
+
for (;;) {
|
|
321
|
+
const n = readSync(fd, buf, size, buf.length - size, null);
|
|
322
|
+
if (n === 0) break;
|
|
323
|
+
size += n;
|
|
324
|
+
if (size === buf.length) break;
|
|
325
|
+
}
|
|
326
|
+
return buf.subarray(0, size);
|
|
327
|
+
} finally {
|
|
328
|
+
closeSync(fd);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The groups file at `path`, read again when it changes, and the decision at a door. `recheckSeconds` 0 looks at the
|
|
333
|
+
* file on every request (one stat), a positive value at most that often; `log` takes one line per change of the file's
|
|
334
|
+
* state (default stderr); `clock` gives monotonic milliseconds (a test replaces it). */
|
|
335
|
+
// A file whose stat is unchanged but whose mtime is this close to now may have been rewritten within one timestamp tick
|
|
336
|
+
// (same size, coarse clock): its content is read and compared (git's racy-clean rule).
|
|
337
|
+
const RACY_NS = 2000000000n;
|
|
338
|
+
|
|
339
|
+
export class TailnetIdentity {
|
|
340
|
+
constructor(path, { recheckSeconds = 0, log, clock = () => performance.now(), service } = {}) {
|
|
341
|
+
if (typeof recheckSeconds !== 'number' || !(recheckSeconds >= 0)) {
|
|
342
|
+
throw new TypeError('recheckSeconds is a number of seconds, 0 or more');
|
|
343
|
+
}
|
|
344
|
+
this.path = String(path);
|
|
345
|
+
this.service = service; // the service's id: the entry a door-groups/1 file is read for
|
|
346
|
+
this.recheckSeconds = recheckSeconds;
|
|
347
|
+
this.log = log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
348
|
+
this.clock = clock;
|
|
349
|
+
this.signature = undefined;
|
|
350
|
+
this.groups = new GroupsError('groups-unreadable', 'not read');
|
|
351
|
+
this.checked = null;
|
|
352
|
+
this.logged = null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
stat() {
|
|
356
|
+
try {
|
|
357
|
+
const st = statSync(this.path, { bigint: true });
|
|
358
|
+
if (!st.isFile()) return 'error:not-a-file';
|
|
359
|
+
this.mtimeNs = st.mtimeNs;
|
|
360
|
+
return [st.dev, st.ino, st.size, st.mtimeNs, st.ctimeNs].join(':');
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return `error:${err?.code ?? 'unknown'}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
read() {
|
|
367
|
+
let data;
|
|
368
|
+
try {
|
|
369
|
+
data = readBounded(this.path);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
this.digest = null;
|
|
372
|
+
return new GroupsError('groups-unreadable', err?.code ?? 'unknown');
|
|
373
|
+
}
|
|
374
|
+
this.digest = createHash('sha256').update(data).digest('hex');
|
|
375
|
+
if (data.length > MAX_GROUPS_BYTES) return invalid('size');
|
|
376
|
+
let text;
|
|
377
|
+
try {
|
|
378
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
379
|
+
} catch {
|
|
380
|
+
return invalid('encoding');
|
|
381
|
+
}
|
|
382
|
+
if (text.charCodeAt(0) === 0xfeff) return invalid('json'); // Python's json refuses a byte-order mark too
|
|
383
|
+
let doc;
|
|
384
|
+
try {
|
|
385
|
+
doc = JSON.parse(text);
|
|
386
|
+
} catch {
|
|
387
|
+
return invalid('json');
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
return loadGroups(doc, { service: this.service });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err instanceof GroupsError) return err;
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The groups in force: the normalized file, or the GroupsError that keeps every door closed. */
|
|
398
|
+
current() {
|
|
399
|
+
const now = this.clock();
|
|
400
|
+
if (this.checked !== null && this.recheckSeconds && now - this.checked < this.recheckSeconds * 1000) {
|
|
401
|
+
return this.groups;
|
|
402
|
+
}
|
|
403
|
+
this.checked = now;
|
|
404
|
+
const signature = this.stat();
|
|
405
|
+
if (signature === this.signature && !signature.startsWith('error:') && this.rewrittenInPlace()) {
|
|
406
|
+
this.signature = undefined;
|
|
407
|
+
}
|
|
408
|
+
if (signature !== this.signature) {
|
|
409
|
+
this.signature = signature;
|
|
410
|
+
this.groups = signature.startsWith('error:')
|
|
411
|
+
? new GroupsError('groups-unreadable', signature.slice(6)) : this.read();
|
|
412
|
+
this.report(signature);
|
|
413
|
+
}
|
|
414
|
+
return this.groups;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Whether a file whose stat did not change holds other bytes: asked only while its mtime is within RACY_NS of now,
|
|
418
|
+
* since a rewrite of the same size within one timestamp tick leaves every stat field as it was (git's racy-clean
|
|
419
|
+
* rule). */
|
|
420
|
+
rewrittenInPlace() {
|
|
421
|
+
const age = BigInt(Date.now()) * 1000000n - this.mtimeNs;
|
|
422
|
+
if (age >= RACY_NS || age <= -RACY_NS) return false;
|
|
423
|
+
try {
|
|
424
|
+
return createHash('sha256').update(readBounded(this.path)).digest('hex') !== this.digest;
|
|
425
|
+
} catch {
|
|
426
|
+
return true; // read it again the usual way, which names the error
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
report(signature) {
|
|
431
|
+
const state = this.groups;
|
|
432
|
+
let key;
|
|
433
|
+
let line;
|
|
434
|
+
if (state instanceof GroupsError) {
|
|
435
|
+
key = `${signature}|${state.code}|${state.where}`;
|
|
436
|
+
line = `fabric: tailnet groups file ${this.path}: ${state.code} (${state.where}); every door refuses until it is fixed`;
|
|
437
|
+
} else {
|
|
438
|
+
key = `${signature}|ok`;
|
|
439
|
+
line = `fabric: tailnet groups file ${this.path} loaded: ${Object.keys(state.groups).length} groups, `
|
|
440
|
+
+ `${state.rules.length} rules`;
|
|
441
|
+
}
|
|
442
|
+
if (key !== this.logged) {
|
|
443
|
+
this.logged = key;
|
|
444
|
+
try { this.log(line); } catch { /* a log that fails never opens a door */ }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** decide() with the groups in force. */
|
|
449
|
+
decide({ bound, peer, headers, door, capability }) {
|
|
450
|
+
return decide({ groups: this.current(), bound, peer, headers, door, capability });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** decide() for a node:http request: the address its server listens on, the connection's peer and the request's
|
|
454
|
+
* headers, each value apart. Behind the kit's loopbackServer the request's own server never listened (the gate
|
|
455
|
+
* did), so pass `bound: gate.address().address`; a bound address nobody can tell is refused (bind-not-loopback). */
|
|
456
|
+
decideRequest(req, { door, capability, bound } = {}) {
|
|
457
|
+
let listening = bound ?? null;
|
|
458
|
+
if (bound === undefined) {
|
|
459
|
+
try {
|
|
460
|
+
listening = req?.socket?.server?.address?.()?.address ?? null;
|
|
461
|
+
} catch {
|
|
462
|
+
listening = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return this.decide({ bound: listening, peer: req?.socket?.remoteAddress ?? null,
|
|
466
|
+
headers: req?.headersDistinct ?? req?.headers ?? {}, door, capability });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
contract's schema reads it (`a2a_surface_findings`, `a2a_view`; `a2a_surface` builds one), and what a public card must
|
|
3
3
|
not carry (`agent_card_findings`): no member about the running process, no secret, no `securitySchemes` entry naming
|
|
4
4
|
the descriptor's token header, every Fabric extension `required: false` (or absent, which A2A reads as false), and interface URLs where the placement puts
|
|
5
|
-
them. Serving a card, the A2A binding and a client are
|
|
5
|
+
them. Serving a card, the A2A binding and a client are in fabric_a2a_server.py (FAA-10). Part of the service kit
|
|
6
|
+
(layer L3).
|
|
6
7
|
Standard library only."""
|
|
7
8
|
from __future__ import annotations
|
|
8
9
|
|