@blacksandscyber/mcp-server-bursar 0.5.0

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 (68) hide show
  1. package/README.md +230 -0
  2. package/build/config.d.ts +45 -0
  3. package/build/config.js +177 -0
  4. package/build/http-transport.d.ts +16 -0
  5. package/build/http-transport.js +191 -0
  6. package/build/index.d.ts +16 -0
  7. package/build/index.js +31 -0
  8. package/build/server.d.ts +41 -0
  9. package/build/server.js +902 -0
  10. package/build/shared/errors.d.ts +50 -0
  11. package/build/shared/errors.js +69 -0
  12. package/build/shared/linkBuilder.d.ts +93 -0
  13. package/build/shared/linkBuilder.js +148 -0
  14. package/build/shared/logger.d.ts +10 -0
  15. package/build/shared/logger.js +28 -0
  16. package/build/shield/bootRole.d.ts +60 -0
  17. package/build/shield/bootRole.js +145 -0
  18. package/build/shield/client.d.ts +265 -0
  19. package/build/shield/client.js +656 -0
  20. package/build/shield/deploy/index.d.ts +69 -0
  21. package/build/shield/deploy/index.js +569 -0
  22. package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
  23. package/build/shield/discovery/dataStoreDetector.js +125 -0
  24. package/build/shield/discovery/dockerScanner.d.ts +34 -0
  25. package/build/shield/discovery/dockerScanner.js +543 -0
  26. package/build/shield/discovery/endpointScanner.d.ts +3 -0
  27. package/build/shield/discovery/endpointScanner.js +306 -0
  28. package/build/shield/discovery/environmentScanner.d.ts +86 -0
  29. package/build/shield/discovery/environmentScanner.js +545 -0
  30. package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
  31. package/build/shield/discovery/externalServiceDetector.js +98 -0
  32. package/build/shield/discovery/frameworkDetector.d.ts +3 -0
  33. package/build/shield/discovery/frameworkDetector.js +114 -0
  34. package/build/shield/discovery/manifestGenerator.d.ts +12 -0
  35. package/build/shield/discovery/manifestGenerator.js +124 -0
  36. package/build/shield/discovery/piiDetector.d.ts +5 -0
  37. package/build/shield/discovery/piiDetector.js +203 -0
  38. package/build/shield/discovery/severity.d.ts +47 -0
  39. package/build/shield/discovery/severity.js +138 -0
  40. package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
  41. package/build/shield/discovery/topologyNormalizer.js +416 -0
  42. package/build/shield/identity.d.ts +53 -0
  43. package/build/shield/identity.js +70 -0
  44. package/build/shield/install/configMerge.d.ts +91 -0
  45. package/build/shield/install/configMerge.js +324 -0
  46. package/build/shield/install/keystore.d.ts +25 -0
  47. package/build/shield/install/keystore.js +156 -0
  48. package/build/shield/install/orchestrator.d.ts +33 -0
  49. package/build/shield/install/orchestrator.js +404 -0
  50. package/build/shield/install/transports/awsSsm.d.ts +43 -0
  51. package/build/shield/install/transports/awsSsm.js +378 -0
  52. package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
  53. package/build/shield/install/transports/bootstrapToken.js +117 -0
  54. package/build/shield/install/transports/ssh.d.ts +50 -0
  55. package/build/shield/install/transports/ssh.js +569 -0
  56. package/build/shield/install/types.d.ts +139 -0
  57. package/build/shield/install/types.js +10 -0
  58. package/build/shield/protocol-walkthrough.d.ts +65 -0
  59. package/build/shield/protocol-walkthrough.js +392 -0
  60. package/build/shield/provision/appProvisioner.d.ts +15 -0
  61. package/build/shield/provision/appProvisioner.js +25 -0
  62. package/build/shield/types.d.ts +261 -0
  63. package/build/shield/types.js +4 -0
  64. package/build/shield/verify/postureReporter.d.ts +4 -0
  65. package/build/shield/verify/postureReporter.js +31 -0
  66. package/dxt/blacksands-ca.crt +67 -0
  67. package/dxt/scripts/setup.js +520 -0
  68. package/package.json +76 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Topology normalizer — assembles the FROZEN topology envelope from the raw
3
+ * macOS environment ({@link scanMacEnvironment}) and the live Shield Broker
4
+ * state ({@link ShieldClient}).
5
+ *
6
+ * The node `type`/`kind`/`status` vocabularies are frozen to match the viz at
7
+ * overview/container-manager/data.js + index.html `dotFor()`. Do not introduce
8
+ * new vocabulary here without updating the viz in lock-step.
9
+ *
10
+ * TRUST RULE (load-bearing): `metadata.trust.authoritative` is true ONLY when
11
+ * the Broker is genuinely reachable AND returned data. When the Broker is
12
+ * unreachable/unauthenticated we emit an EMPTY zt plane, source "none", and we
13
+ * never synthesize devices/sessions or any `active` data edge.
14
+ */
15
+ import type { RawEnvironment } from "./environmentScanner";
16
+ export type InfraNodeType = "host" | "network" | "container" | "volume" | "service";
17
+ export type InfraEdgeKind = "host" | "net" | "vol";
18
+ export type ZtNodeType = "manager" | "authorizer" | "receiver" | "device" | "service";
19
+ export type ZtEdgeKind = "ctrl" | "data" | "proxy";
20
+ export type ZtDataState = "active" | "pending" | "denied" | "revoked";
21
+ export interface LayoutHint {
22
+ tier: number;
23
+ group: string;
24
+ }
25
+ export interface TopologyNode {
26
+ id: string;
27
+ type: InfraNodeType | ZtNodeType;
28
+ name: string;
29
+ sub: string;
30
+ status?: string;
31
+ cpu?: number;
32
+ mem?: number;
33
+ detail: Record<string, unknown>;
34
+ layoutHint?: LayoutHint;
35
+ }
36
+ export interface TopologyEdge {
37
+ from: string;
38
+ to: string;
39
+ kind: InfraEdgeKind | ZtEdgeKind;
40
+ state?: ZtDataState;
41
+ proto?: string;
42
+ }
43
+ export interface TopologyPlane {
44
+ nodes: TopologyNode[];
45
+ edges: TopologyEdge[];
46
+ }
47
+ export interface TopologyEnvelope {
48
+ schemaVersion: "1.0";
49
+ metadata: {
50
+ provider: "macos" | "docker" | "aws" | "azure";
51
+ generatedAt: string;
52
+ host: string;
53
+ planes: Array<"infra" | "zt">;
54
+ trust: {
55
+ source: "shield-broker" | "none";
56
+ brokerReachable: boolean;
57
+ authoritative: boolean;
58
+ };
59
+ notes?: string[];
60
+ };
61
+ infra: TopologyPlane;
62
+ zt: TopologyPlane;
63
+ }
64
+ export declare function buildInfraPlane(env: RawEnvironment, provider?: "macos" | "docker" | "aws" | "azure"): TopologyPlane;
65
+ /** Minimal structural contract over ShieldClient — only what this module uses. */
66
+ export interface ZtSource {
67
+ readonly orgId: string;
68
+ listReceivers(filters?: {
69
+ status?: string;
70
+ organizationId?: string;
71
+ }): Promise<Record<string, unknown>[]>;
72
+ listApps(orgId: string): Promise<{
73
+ data: Array<{
74
+ id: string;
75
+ name: string;
76
+ status: string;
77
+ }>;
78
+ }>;
79
+ listSessions(appId: string): Promise<{
80
+ data: Array<Record<string, unknown>>;
81
+ }>;
82
+ listEndpoints(appId: string): Promise<{
83
+ data: Array<Record<string, unknown>>;
84
+ }>;
85
+ }
86
+ export interface ZtBuildResult {
87
+ plane: TopologyPlane;
88
+ trust: {
89
+ source: "shield-broker" | "none";
90
+ brokerReachable: boolean;
91
+ authoritative: boolean;
92
+ };
93
+ notes: string[];
94
+ }
95
+ /**
96
+ * Build the ZT plane from live Broker state. If `src` is null (no Shield access
97
+ * configured) or any reachability/auth check fails, returns an EMPTY plane with
98
+ * non-authoritative trust. Never throws and never fabricates trust.
99
+ */
100
+ export declare function buildZtPlane(src: ZtSource | null, appIdScope?: string): Promise<ZtBuildResult>;
101
+ export interface AssembleInput {
102
+ provider: "macos" | "docker" | "aws" | "azure";
103
+ planes: Array<"infra" | "zt">;
104
+ env: RawEnvironment | null;
105
+ zt: ZtBuildResult | null;
106
+ extraNotes?: string[];
107
+ }
108
+ export declare function assembleEnvelope(input: AssembleInput): TopologyEnvelope;
109
+ //# sourceMappingURL=topologyNormalizer.d.ts.map
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+ /**
3
+ * Topology normalizer — assembles the FROZEN topology envelope from the raw
4
+ * macOS environment ({@link scanMacEnvironment}) and the live Shield Broker
5
+ * state ({@link ShieldClient}).
6
+ *
7
+ * The node `type`/`kind`/`status` vocabularies are frozen to match the viz at
8
+ * overview/container-manager/data.js + index.html `dotFor()`. Do not introduce
9
+ * new vocabulary here without updating the viz in lock-step.
10
+ *
11
+ * TRUST RULE (load-bearing): `metadata.trust.authoritative` is true ONLY when
12
+ * the Broker is genuinely reachable AND returned data. When the Broker is
13
+ * unreachable/unauthenticated we emit an EMPTY zt plane, source "none", and we
14
+ * never synthesize devices/sessions or any `active` data edge.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.buildInfraPlane = buildInfraPlane;
18
+ exports.buildZtPlane = buildZtPlane;
19
+ exports.assembleEnvelope = assembleEnvelope;
20
+ const logger_1 = require("../../shared/logger");
21
+ // `dotFor()` known vocabulary — keep node.status within these or omit it.
22
+ const GREEN = new Set(["running", "healthy", "active", "compliant", "attested", "reachable", "online", "mounted"]);
23
+ const AMBER = new Set(["pending", "degraded"]);
24
+ const RED = new Set(["stopped", "denied", "blocked", "revoked", "offline"]);
25
+ function safeStatus(s) {
26
+ if (!s)
27
+ return undefined;
28
+ const v = s.toLowerCase();
29
+ if (GREEN.has(v) || AMBER.has(v) || RED.has(v))
30
+ return v;
31
+ return undefined; // unknown → omit so dotFor() falls through cleanly
32
+ }
33
+ // ── infra plane ──────────────────────────────────────────────────────────────
34
+ function netId(name) {
35
+ return `net-${name}`;
36
+ }
37
+ function ctrId(idOrName) {
38
+ return `ctr-${idOrName}`;
39
+ }
40
+ function volId(name) {
41
+ return `vol-${name}`;
42
+ }
43
+ function svcId(address, port) {
44
+ return `svc-${address}:${port}`.replace(/[*]/g, "any");
45
+ }
46
+ // A container's command line is surfaced into the topology (detail.cmd) and out
47
+ // the local API. argv frequently embeds credentials (e.g. `--password=…`,
48
+ // `mysql -psecret`, `postgres://user:pass@host`, bearer tokens). Redact those
49
+ // before exposing them — this is a security product; the viz only needs the
50
+ // gist of the command, not its secrets.
51
+ function redactSecrets(cmd) {
52
+ if (!cmd)
53
+ return cmd;
54
+ return cmd
55
+ // postgres://user:pass@host → postgres://user:***@host
56
+ .replace(/(\/\/[^/\s:]+:)[^@\s/]+@/g, "$1***@")
57
+ // --password=secret / --token foo / -p secret (flag + value)
58
+ .replace(/(--?(?:password|passwd|pass|pwd|token|secret|api[-_]?key|apikey|access[-_]?key|auth)[=\s]+)\S+/gi, "$1***")
59
+ // bare -pSECRET (mysql-style, no space)
60
+ .replace(/(\s-p)(?=\S)\S+/g, "$1***");
61
+ }
62
+ function buildInfraPlane(env, provider = "macos") {
63
+ const nodes = [];
64
+ const edges = [];
65
+ // Dedupe edges (a container can mount the same volume twice, etc.).
66
+ const seenEdge = new Set();
67
+ const pushEdge = (e) => {
68
+ const k = `${e.from}::${e.to}::${e.kind}`;
69
+ if (seenEdge.has(k))
70
+ return;
71
+ seenEdge.add(k);
72
+ edges.push(e);
73
+ };
74
+ // host (tier 0)
75
+ const hostNodeId = "host";
76
+ const runningCount = env.containers.filter((c) => c.running).length;
77
+ nodes.push({
78
+ id: hostNodeId,
79
+ type: "host",
80
+ name: env.host.hostname,
81
+ sub: provider === "docker" ? "Docker Engine" : "Apple Containerization",
82
+ status: "healthy",
83
+ detail: {
84
+ arch: env.host.arch,
85
+ platform: env.host.platform,
86
+ kernel: env.host.release,
87
+ cpu: env.host.cpuModel,
88
+ vcpu: env.host.vcpu,
89
+ memory: env.host.totalMemGB,
90
+ running: runningCount,
91
+ stopped: env.containers.length - runningCount,
92
+ containerCliPresent: env.host.containerCliPresent,
93
+ interfaces: env.host.interfaces,
94
+ },
95
+ layoutHint: { tier: 0, group: "host" },
96
+ });
97
+ // networks (tier 1) — edge host→network kind "host"
98
+ const knownNetworks = new Set(env.networks.map((n) => n.name));
99
+ for (const n of env.networks) {
100
+ nodes.push({
101
+ id: netId(n.name),
102
+ type: "network",
103
+ name: n.name,
104
+ sub: n.subnet ?? n.driver ?? "network",
105
+ status: undefined,
106
+ detail: { subnet: n.subnet, gateway: n.gateway, driver: n.driver },
107
+ layoutHint: { tier: 1, group: "network" },
108
+ });
109
+ pushEdge({ from: hostNodeId, to: netId(n.name), kind: "host" });
110
+ }
111
+ // containers (tier 2) — edge network→container kind "net"
112
+ for (const c of env.containers) {
113
+ const status = safeStatus(c.status) ?? (c.running ? "running" : "stopped");
114
+ const node = {
115
+ id: ctrId(c.id),
116
+ type: "container",
117
+ name: c.name,
118
+ sub: c.image ?? "container",
119
+ status,
120
+ detail: {
121
+ image: c.image,
122
+ ip: c.ip,
123
+ ports: c.ports,
124
+ networks: c.networks,
125
+ volumes: c.volumes,
126
+ cmd: redactSecrets(c.command),
127
+ rawStatus: c.status,
128
+ },
129
+ layoutHint: { tier: 2, group: "container" },
130
+ };
131
+ // Only running containers carry live cpu/mem. The macOS scanner has no live
132
+ // metrics (omitted, not fabricated); the Docker scanner attaches them from
133
+ // `docker stats`. Surface them on the node when present so the viz metric
134
+ // bars are live.
135
+ if (c.running && typeof c.cpu === "number")
136
+ node.cpu = c.cpu;
137
+ if (c.running && typeof c.mem === "number")
138
+ node.mem = c.mem;
139
+ nodes.push(node);
140
+ for (const netName of c.networks) {
141
+ if (knownNetworks.has(netName)) {
142
+ pushEdge({ from: netId(netName), to: ctrId(c.id), kind: "net" });
143
+ }
144
+ }
145
+ // If a container references no known network, attach it to the host so the
146
+ // viz still places it (degraded-but-visible).
147
+ if (!c.networks.some((nm) => knownNetworks.has(nm))) {
148
+ pushEdge({ from: hostNodeId, to: ctrId(c.id), kind: "host" });
149
+ }
150
+ }
151
+ // volumes (tier 3) — edge container→volume kind "vol"
152
+ const declaredVolumes = new Map();
153
+ for (const v of env.volumes) {
154
+ const node = {
155
+ id: volId(v.name),
156
+ type: "volume",
157
+ name: v.name,
158
+ sub: v.driver ?? "local",
159
+ status: "mounted",
160
+ detail: { driver: v.driver, source: v.source, usedBy: [] },
161
+ layoutHint: { tier: 3, group: "volume" },
162
+ };
163
+ declaredVolumes.set(v.name, node);
164
+ nodes.push(node);
165
+ }
166
+ for (const c of env.containers) {
167
+ for (const vname of c.volumes) {
168
+ let target = declaredVolumes.get(vname);
169
+ if (!target) {
170
+ // Container references a volume the `volume ls` didn't list — synthesize
171
+ // a node from the reference so the edge isn't dangling.
172
+ target = {
173
+ id: volId(vname),
174
+ type: "volume",
175
+ name: vname,
176
+ sub: "local",
177
+ status: "mounted",
178
+ detail: { usedBy: [] },
179
+ layoutHint: { tier: 3, group: "volume" },
180
+ };
181
+ declaredVolumes.set(vname, target);
182
+ nodes.push(target);
183
+ }
184
+ target.detail.usedBy.push(c.name);
185
+ pushEdge({ from: ctrId(c.id), to: volId(vname), kind: "vol" });
186
+ }
187
+ }
188
+ // candidate service nodes from listening ports (tier 2, alongside containers).
189
+ // These are infra-plane `service` nodes (optional per contract), attached to
190
+ // the host. We dedupe and skip noise.
191
+ for (const p of env.listeningPorts) {
192
+ const id = svcId(p.address, p.port);
193
+ if (nodes.some((n) => n.id === id))
194
+ continue;
195
+ nodes.push({
196
+ id,
197
+ type: "service",
198
+ name: `${p.process ?? "service"}:${p.port}`,
199
+ sub: `${p.proto} · ${p.address === "*" ? "0.0.0.0" : p.address}:${p.port}`,
200
+ status: "online",
201
+ detail: { port: p.port, proto: p.proto, address: p.address, process: p.process, pid: p.pid },
202
+ layoutHint: { tier: 2, group: "service" },
203
+ });
204
+ pushEdge({ from: hostNodeId, to: id, kind: "host" });
205
+ }
206
+ return { nodes, edges };
207
+ }
208
+ function str(rec, ...keys) {
209
+ for (const k of keys) {
210
+ const v = rec[k];
211
+ if (typeof v === "string" && v)
212
+ return v;
213
+ if (typeof v === "number")
214
+ return String(v);
215
+ }
216
+ return null;
217
+ }
218
+ function mapSessionState(raw) {
219
+ switch ((raw ?? "").toLowerCase()) {
220
+ case "active":
221
+ return "active";
222
+ case "pending":
223
+ return "pending";
224
+ case "revoked":
225
+ return "revoked";
226
+ case "expired":
227
+ case "denied":
228
+ return "denied";
229
+ default:
230
+ return "pending"; // conservative: never default to "active"
231
+ }
232
+ }
233
+ /**
234
+ * Build the ZT plane from live Broker state. If `src` is null (no Shield access
235
+ * configured) or any reachability/auth check fails, returns an EMPTY plane with
236
+ * non-authoritative trust. Never throws and never fabricates trust.
237
+ */
238
+ async function buildZtPlane(src, appIdScope) {
239
+ const empty = () => ({
240
+ plane: { nodes: [], edges: [] },
241
+ trust: { source: "none", brokerReachable: false, authoritative: false },
242
+ notes: ["ZT plane empty: Broker unreachable or unauthenticated — trust not authoritative"],
243
+ });
244
+ if (!src) {
245
+ return empty();
246
+ }
247
+ const notes = [];
248
+ let receivers = [];
249
+ let reachable = false;
250
+ // The Manager node = the Broker connection itself. Reachability is proven by
251
+ // a real call returning data, not by a flag.
252
+ try {
253
+ receivers = await src.listReceivers({ organizationId: src.orgId || undefined });
254
+ reachable = true;
255
+ }
256
+ catch (err) {
257
+ logger_1.logger.info("buildZtPlane: Broker unreachable — emitting empty ZT plane", { err: String(err) });
258
+ return empty();
259
+ }
260
+ const nodes = [];
261
+ const edges = [];
262
+ // manager (tier 0)
263
+ const mgrId = "zt-mgr";
264
+ nodes.push({
265
+ id: mgrId,
266
+ type: "manager",
267
+ name: "Shield Broker",
268
+ sub: "Control plane",
269
+ status: "healthy",
270
+ detail: { role: "Manager · control plane", orgId: src.orgId || null, mtls: "root of trust", source: "shield-broker" },
271
+ layoutHint: { tier: 0, group: "manager" },
272
+ });
273
+ // receivers (tier 2) — ctrl edge manager→receiver
274
+ for (const r of receivers) {
275
+ const uid = str(r, "receiverUID", "receiverUid", "uid", "id");
276
+ if (!uid)
277
+ continue;
278
+ const rawStatus = str(r, "status", "state");
279
+ nodes.push({
280
+ id: `zt-rcv-${uid}`,
281
+ type: "receiver",
282
+ name: str(r, "name", "hostname", "receiverUID") ?? uid,
283
+ sub: str(r, "region", "location") ?? "receiver",
284
+ status: safeStatus(rawStatus) ?? "healthy",
285
+ detail: { receiverUID: uid, region: str(r, "region"), upstream: "Shield Broker", rawStatus },
286
+ layoutHint: { tier: 2, group: "receiver" },
287
+ });
288
+ edges.push({ from: mgrId, to: `zt-rcv-${uid}`, kind: "ctrl" });
289
+ }
290
+ // Apps in scope → enrich with sessions + endpoints (devices/services).
291
+ let apps = [];
292
+ if (src.orgId) {
293
+ try {
294
+ const resp = await src.listApps(src.orgId);
295
+ apps = resp.data ?? [];
296
+ }
297
+ catch (err) {
298
+ notes.push(`listApps failed (${String(err)}); ZT plane limited to Manager + Receivers`);
299
+ }
300
+ }
301
+ else {
302
+ notes.push("No org context (SHIELD_ORG_ID unset); skipping session/endpoint enrichment");
303
+ }
304
+ if (appIdScope) {
305
+ apps = apps.filter((a) => a.id === appIdScope);
306
+ if (!apps.length)
307
+ notes.push(`appId ${appIdScope} not found in org ${src.orgId}`);
308
+ }
309
+ const deviceIds = new Set();
310
+ for (const app of apps) {
311
+ // services (tier 4) from endpoints
312
+ try {
313
+ const eps = await src.listEndpoints(app.id);
314
+ for (const e of eps.data ?? []) {
315
+ const epId = str(e, "id");
316
+ if (!epId)
317
+ continue;
318
+ const id = `zt-svc-${epId}`;
319
+ nodes.push({
320
+ id,
321
+ type: "service",
322
+ name: str(e, "host") ?? `endpoint ${epId}`,
323
+ sub: `${str(e, "protocol") ?? "tcp"} · :${str(e, "port") ?? "?"}`,
324
+ status: (str(e, "status") ?? "").toLowerCase() === "active" ? "reachable" : undefined,
325
+ detail: { appId: app.id, host: str(e, "host"), port: str(e, "port"), protocol: str(e, "protocol"), exposure: "receiver-only" },
326
+ layoutHint: { tier: 4, group: "service" },
327
+ });
328
+ }
329
+ }
330
+ catch (err) {
331
+ notes.push(`listEndpoints(${app.id}) failed (${String(err)})`);
332
+ }
333
+ // devices (tier 1) + data edges from sessions
334
+ try {
335
+ const sessions = await src.listSessions(app.id);
336
+ for (const s of sessions.data ?? []) {
337
+ const sid = str(s, "id");
338
+ const userId = str(s, "userId", "user", "identity") ?? sid;
339
+ if (!userId)
340
+ continue;
341
+ const devId = `zt-dev-${userId}`;
342
+ const state = mapSessionState(str(s, "status", "state"));
343
+ if (!deviceIds.has(devId)) {
344
+ deviceIds.add(devId);
345
+ nodes.push({
346
+ id: devId,
347
+ type: "device",
348
+ name: userId,
349
+ sub: "Shield identity",
350
+ // device status derives from session: active→compliant, else pending
351
+ status: state === "active" ? "compliant" : state === "revoked" || state === "denied" ? "blocked" : "pending",
352
+ detail: { identity: userId, appId: app.id, mtls: "device cert" },
353
+ layoutHint: { tier: 1, group: "device" },
354
+ });
355
+ // ctrl edge device→manager (auth path)
356
+ edges.push({ from: devId, to: mgrId, kind: "ctrl" });
357
+ }
358
+ // data edge device→receiver, ONLY with a real session-derived state.
359
+ // We attach to the first receiver if present; the session has no
360
+ // receiver binding in the public shape, so this is a coarse link.
361
+ const firstReceiver = receivers.length ? str(receivers[0], "receiverUID", "uid", "id") : null;
362
+ if (firstReceiver) {
363
+ edges.push({
364
+ from: devId,
365
+ to: `zt-rcv-${firstReceiver}`,
366
+ kind: "data",
367
+ state,
368
+ proto: str(s, "protocol") ?? "mTLS",
369
+ });
370
+ }
371
+ }
372
+ }
373
+ catch (err) {
374
+ notes.push(`listSessions(${app.id}) failed (${String(err)})`);
375
+ }
376
+ }
377
+ // Authoritative ONLY when the Broker was reachable AND returned data.
378
+ const hasData = receivers.length > 0 || apps.length > 0;
379
+ const authoritative = reachable && hasData;
380
+ if (reachable && !hasData) {
381
+ notes.push("Broker reachable but returned no receivers/apps for this org; trust authoritative=false (no data)");
382
+ }
383
+ return {
384
+ plane: { nodes, edges },
385
+ trust: { source: "shield-broker", brokerReachable: reachable, authoritative },
386
+ notes,
387
+ };
388
+ }
389
+ function assembleEnvelope(input) {
390
+ const wantInfra = input.planes.includes("infra");
391
+ const wantZt = input.planes.includes("zt");
392
+ const infra = wantInfra && input.env ? buildInfraPlane(input.env, input.provider) : { nodes: [], edges: [] };
393
+ const zt = wantZt && input.zt ? input.zt.plane : { nodes: [], edges: [] };
394
+ const trust = input.zt
395
+ ? input.zt.trust
396
+ : { source: "none", brokerReachable: false, authoritative: false };
397
+ const notes = [
398
+ ...(input.env?.notes ?? []),
399
+ ...(input.zt?.notes ?? []),
400
+ ...(input.extraNotes ?? []),
401
+ ];
402
+ return {
403
+ schemaVersion: "1.0",
404
+ metadata: {
405
+ provider: input.provider,
406
+ generatedAt: new Date().toISOString(),
407
+ host: input.env?.host.hostname ?? "unknown",
408
+ planes: input.planes,
409
+ trust,
410
+ ...(notes.length ? { notes } : {}),
411
+ },
412
+ infra,
413
+ zt,
414
+ };
415
+ }
416
+ //# sourceMappingURL=topologyNormalizer.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * MCP-side identity helper.
3
+ *
4
+ * Wraps GET /v1/mcp/identity (added in the role-RBAC commit on the Shield
5
+ * API side). The MCP server's broker_get_my_identity tool calls this so
6
+ * the LLM can introspect:
7
+ * - which cert it's authenticated as
8
+ * - what role that cert has (master | consumer)
9
+ * - what org it belongs to
10
+ *
11
+ * Same Shield API request shape as every other call — fetched lazily through
12
+ * the BrokerConnector / ShieldClient so the broker handshake is reused.
13
+ *
14
+ * Why a separate module from src/shield/client.ts:
15
+ * client.ts is the canonical Shield API client (45 admin tool methods).
16
+ * Identity introspection is a different concern: it's used by ONE MCP
17
+ * tool, doesn't fit the existing taxonomy, and is small enough that a
18
+ * separate file improves discoverability.
19
+ */
20
+ import type { ShieldClient } from "./client";
21
+ export type Role = "master" | "consumer";
22
+ export interface ClientIdentity {
23
+ /** Common Name from the cert presented to the broker handshake. */
24
+ cn: string | null;
25
+ /** RBAC role. Drives MCP tool filtering and Shield API requireMcpRole checks. */
26
+ role: Role;
27
+ /** Organization the cert is registered to. May be null for service accounts. */
28
+ orgId: string | null;
29
+ /** Friendly client name (e.g. "claude-desktop-userx2"). */
30
+ clientName: string | null;
31
+ /** Service tier (essentials | professional | enterprise). */
32
+ tier: string | null;
33
+ /** True for Blacksands-internal trusted backends (Overwatch, Architect, etc.). */
34
+ isServiceAccount: boolean;
35
+ }
36
+ /**
37
+ * Fetch the calling cert's identity from Shield API.
38
+ *
39
+ * Throws on:
40
+ * - broker handshake failure
41
+ * - non-200 from /v1/mcp/identity
42
+ * - malformed response shape
43
+ */
44
+ export declare function fetchClientIdentity(client: ShieldClient): Promise<ClientIdentity>;
45
+ /**
46
+ * Synthetic identity used when the MCP server is in local-only mode
47
+ * (no broker connection possible). Always Consumer — the prototypical
48
+ * use case for local-only is "developer ran npx -y @blacksandscyber/
49
+ * mcp-server-shield with no token", which is exactly the Consumer
50
+ * persona.
51
+ */
52
+ export declare function localOnlyIdentity(): ClientIdentity;
53
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * MCP-side identity helper.
4
+ *
5
+ * Wraps GET /v1/mcp/identity (added in the role-RBAC commit on the Shield
6
+ * API side). The MCP server's broker_get_my_identity tool calls this so
7
+ * the LLM can introspect:
8
+ * - which cert it's authenticated as
9
+ * - what role that cert has (master | consumer)
10
+ * - what org it belongs to
11
+ *
12
+ * Same Shield API request shape as every other call — fetched lazily through
13
+ * the BrokerConnector / ShieldClient so the broker handshake is reused.
14
+ *
15
+ * Why a separate module from src/shield/client.ts:
16
+ * client.ts is the canonical Shield API client (45 admin tool methods).
17
+ * Identity introspection is a different concern: it's used by ONE MCP
18
+ * tool, doesn't fit the existing taxonomy, and is small enough that a
19
+ * separate file improves discoverability.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.fetchClientIdentity = fetchClientIdentity;
23
+ exports.localOnlyIdentity = localOnlyIdentity;
24
+ /**
25
+ * Fetch the calling cert's identity from Shield API.
26
+ *
27
+ * Throws on:
28
+ * - broker handshake failure
29
+ * - non-200 from /v1/mcp/identity
30
+ * - malformed response shape
31
+ */
32
+ async function fetchClientIdentity(client) {
33
+ // ShieldClient.request() handles the broker handshake + URL resolution +
34
+ // session-token attachment. We just need to GET the right path. Returned
35
+ // shape is the Shield API envelope { success, identity }.
36
+ const env = await client.request("GET", "/mcp/identity");
37
+ if (!env || typeof env !== "object") {
38
+ throw new Error("Shield API /v1/mcp/identity returned a non-object response");
39
+ }
40
+ if (env.success !== true || !env.identity) {
41
+ throw new Error(`Shield API /v1/mcp/identity rejected the call: ${env.message || "no identity returned"}`);
42
+ }
43
+ const i = env.identity;
44
+ return {
45
+ cn: i.cn ?? null,
46
+ role: (i.role === "consumer" ? "consumer" : "master"),
47
+ orgId: i.orgId ?? null,
48
+ clientName: i.clientName ?? null,
49
+ tier: i.tier ?? null,
50
+ isServiceAccount: i.isServiceAccount ?? false,
51
+ };
52
+ }
53
+ /**
54
+ * Synthetic identity used when the MCP server is in local-only mode
55
+ * (no broker connection possible). Always Consumer — the prototypical
56
+ * use case for local-only is "developer ran npx -y @blacksandscyber/
57
+ * mcp-server-shield with no token", which is exactly the Consumer
58
+ * persona.
59
+ */
60
+ function localOnlyIdentity() {
61
+ return {
62
+ cn: null,
63
+ role: "consumer",
64
+ orgId: null,
65
+ clientName: null,
66
+ tier: null,
67
+ isServiceAccount: false,
68
+ };
69
+ }
70
+ //# sourceMappingURL=identity.js.map