@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,404 @@
1
+ "use strict";
2
+ /**
3
+ * Orchestrator for bursar_install_agent_remotely.
4
+ *
5
+ * Coordinates: precheck → transport.deliver → (config merge + restart are
6
+ * the target's job for bootstrap-token; handled by the transport itself
7
+ * for SSH/SSM in later phases) → handshake verification → rollback on
8
+ * failure.
9
+ *
10
+ * The orchestrator is intentionally thin. Transport-specific behavior
11
+ * lives in the Transport classes; config-file manipulation lives in
12
+ * configMerge. This file is just the state machine.
13
+ *
14
+ * See SHIELD-INSTALL-AGENT-REMOTELY-REQUIREMENTS.md §FR-1 through §FR-12.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.runInstall = runInstall;
18
+ exports.runInstallWithRollback = runInstallWithRollback;
19
+ const crypto_1 = require("crypto");
20
+ const bootstrapToken_1 = require("./transports/bootstrapToken");
21
+ const ssh_1 = require("./transports/ssh");
22
+ const awsSsm_1 = require("./transports/awsSsm");
23
+ const configMerge_1 = require("./configMerge");
24
+ const errors_1 = require("../../shared/errors");
25
+ const logger_1 = require("../../shared/logger");
26
+ // ──────────────────────────────────────────────────────────────────────────
27
+ // Transport factory
28
+ // ──────────────────────────────────────────────────────────────────────────
29
+ function buildTransport(shield, spec) {
30
+ switch (spec.type) {
31
+ case "bootstrap-token": return new bootstrapToken_1.BootstrapTokenTransport(shield, spec);
32
+ case "ssh": return new ssh_1.SshTransport(shield, spec);
33
+ case "aws-ssm": return new awsSsm_1.AwsSsmTransport(shield, spec);
34
+ }
35
+ }
36
+ // ──────────────────────────────────────────────────────────────────────────
37
+ // Target redaction — strip anything remotely sensitive before echoing
38
+ // the transport spec in the tool response (§5 output contract)
39
+ // ──────────────────────────────────────────────────────────────────────────
40
+ function redactTarget(spec) {
41
+ switch (spec.type) {
42
+ case "ssh":
43
+ return {
44
+ type: spec.type,
45
+ host: spec.host,
46
+ port: spec.port,
47
+ username: spec.username,
48
+ authMethod: spec.authMethod,
49
+ sudo: spec.sudo,
50
+ hasHostKeyFingerprint: !!spec.hostKeyFingerprint,
51
+ };
52
+ case "bootstrap-token":
53
+ return {
54
+ type: spec.type,
55
+ ttlSeconds: spec.ttlSeconds,
56
+ deliverVia: spec.deliverVia,
57
+ oneShot: spec.oneShot,
58
+ hasAllowedCidr: !!spec.allowedCidr,
59
+ hasEmail: !!spec.email,
60
+ };
61
+ case "aws-ssm":
62
+ return {
63
+ type: spec.type,
64
+ instanceId: spec.instanceId,
65
+ region: spec.region || null,
66
+ };
67
+ }
68
+ }
69
+ // ──────────────────────────────────────────────────────────────────────────
70
+ // Idempotency precheck (§FR-10)
71
+ // ──────────────────────────────────────────────────────────────────────────
72
+ async function precheckExisting(shield, clientName) {
73
+ try {
74
+ const resp = await shield.listMcpCerts();
75
+ const existing = (resp.data || []).find(c => c.name === clientName);
76
+ if (existing) {
77
+ return { conflict: true, existing };
78
+ }
79
+ return { conflict: false };
80
+ }
81
+ catch (err) {
82
+ // If we can't list, fall through rather than blocking the install;
83
+ // the Shield API will reject a duplicate on issue-cert anyway.
84
+ logger_1.logger.warn("install precheck: could not list MCP certs, proceeding", {
85
+ error: err.message,
86
+ });
87
+ return { conflict: false };
88
+ }
89
+ }
90
+ // ──────────────────────────────────────────────────────────────────────────
91
+ // Default restart command suggestions (§FR-6)
92
+ // ──────────────────────────────────────────────────────────────────────────
93
+ function defaultRestartCommand(agentType, clientName) {
94
+ switch (agentType) {
95
+ case "claude-desktop":
96
+ // §10.3: don't automate quit-and-reopen; instruct user to restart.
97
+ return null;
98
+ case "mcp-server":
99
+ return `systemctl --user restart blacksands-${clientName}`;
100
+ case "openclaw":
101
+ return `systemctl --user restart openclaw-${clientName}`;
102
+ case "custom":
103
+ return null;
104
+ }
105
+ }
106
+ async function runInstall(input, deps) {
107
+ const auditId = (0, crypto_1.randomUUID)();
108
+ const startedAt = Date.now();
109
+ const authorizerUrl = input.authorizerUrl || deps.defaultAuthorizerUrl;
110
+ if (!authorizerUrl) {
111
+ throw new errors_1.InstallError("authorizerUrl was not provided and this MCP server does not have a default Authorizer configured (http-service mode without SHIELD_AUTHORIZER_URL)", {
112
+ phase: "precheck",
113
+ clientName: input.clientName,
114
+ next_steps: [
115
+ "Provide `authorizerUrl` explicitly, e.g. 'https://auth.beta.blacksandscyber.online'.",
116
+ ],
117
+ }, 400);
118
+ }
119
+ const configPath = (0, configMerge_1.resolveConfigPath)(input.agentType, input.configPath);
120
+ const redactedTarget = redactTarget(input.transport);
121
+ const revokeHint = `bursar_revoke_mcp_cert({ clientName: "${input.clientName}" })`;
122
+ logger_1.logger.info("install: start", {
123
+ auditId,
124
+ clientName: input.clientName,
125
+ orgId: input.orgId,
126
+ agentType: input.agentType,
127
+ transport: input.transport.type,
128
+ dryRun: input.dryRun,
129
+ });
130
+ // ── Dry-run short-circuit (§FR-9) ──────────────────────────────────────
131
+ if (input.dryRun) {
132
+ const plannedRestart = input.restartCmd || defaultRestartCommand(input.agentType, input.clientName) || "(none — manual restart)";
133
+ return {
134
+ status: "dry-run",
135
+ clientName: input.clientName,
136
+ clientId: `dry-run:${auditId}`,
137
+ transport: input.transport.type,
138
+ target: redactedTarget,
139
+ installedFiles: [],
140
+ configPath,
141
+ handshake: { verified: false, reason: "dry-run" },
142
+ auditId,
143
+ revokeHint,
144
+ next_steps: [
145
+ `(dry-run) Would mint/issue credentials for clientName="${input.clientName}" in orgId="${input.orgId}".`,
146
+ `(dry-run) Would write cert bundle + merge config at ${configPath}.`,
147
+ `(dry-run) Would issue restart: ${plannedRestart}`,
148
+ `(dry-run) Would verify handshake through ${authorizerUrl}.`,
149
+ "Remove `dryRun: true` to actually perform the install.",
150
+ ],
151
+ warnings: input.transport.type === "ssh" || input.transport.type === "aws-ssm"
152
+ ? [
153
+ `${input.transport.type} transport is a Phase A.2/B stub — dry-run reports as if it would work, but live execution will throw InstallError('transport-not-implemented').`,
154
+ ]
155
+ : undefined,
156
+ };
157
+ }
158
+ // ── Precheck: duplicate clientName? (§FR-10) ───────────────────────────
159
+ const precheck = await precheckExisting(deps.shield, input.clientName);
160
+ if (precheck.conflict) {
161
+ throw new errors_1.InstallError(`ALREADY_INSTALLED: a cert for clientName="${input.clientName}" already exists`, {
162
+ phase: "precheck",
163
+ clientName: input.clientName,
164
+ next_steps: [
165
+ `Existing cert: CN=${precheck.existing.cn}, fingerprint=${precheck.existing.fingerprint.slice(0, 16)}…, expires=${precheck.existing.expires}`,
166
+ `To re-issue, revoke the existing cert first: ${revokeHint}`,
167
+ "Or pick a different clientName.",
168
+ ],
169
+ }, 409);
170
+ }
171
+ // ── Transport.deliver ───────────────────────────────────────────────────
172
+ const transport = buildTransport(deps.shield, input.transport);
173
+ let delivery;
174
+ try {
175
+ delivery = await transport.deliver({
176
+ clientName: input.clientName,
177
+ orgId: input.orgId,
178
+ agentType: input.agentType,
179
+ configPath,
180
+ serviceId: input.serviceId,
181
+ authorizerUrl,
182
+ role: input.role,
183
+ });
184
+ }
185
+ catch (err) {
186
+ // Delivery failures haven't mutated anything yet — nothing to roll back,
187
+ // but we still re-throw as an InstallError for consistent error shape.
188
+ if (err instanceof errors_1.InstallError)
189
+ throw err;
190
+ throw new errors_1.InstallError(`Transport '${input.transport.type}' failed to deliver: ${err.message}`, {
191
+ phase: "connect-transport",
192
+ transport: input.transport.type,
193
+ clientName: input.clientName,
194
+ cause: { name: err.name, message: err.message },
195
+ }, 502);
196
+ }
197
+ // ── Handshake verification (§FR-7) ─────────────────────────────────────
198
+ // bootstrap-token deliveries return status: "bootstrap-pending" — nothing
199
+ // to verify yet, because the target hasn't redeemed the token.
200
+ // For synchronous transports (ssh), poll the Shield API until the
201
+ // newly-issued cert authenticates or we time out.
202
+ let handshake;
203
+ if (input.waitForHandshake && delivery.status === "installed") {
204
+ handshake = await pollHandshake(deps.shield, input.clientName, input.handshakeTimeoutMs, startedAt);
205
+ }
206
+ else {
207
+ handshake = buildHandshakeResult(input, delivery.status, Date.now() - startedAt);
208
+ }
209
+ const next_steps = [...delivery.next_steps];
210
+ if (delivery.status === "bootstrap-pending") {
211
+ next_steps.push("After the target completes installation, run bursar_list_mcp_certs to confirm the new identity is registered.", "Once the agent is up, run receiver_onboard_service to grant it access to specific backend services (governance default is deny-all — §FR-12).");
212
+ }
213
+ // ── Compose result (§5 output contract) ────────────────────────────────
214
+ return {
215
+ status: delivery.status,
216
+ clientName: input.clientName,
217
+ clientId: delivery.clientId,
218
+ fingerprint: delivery.fingerprint,
219
+ expires: delivery.expires,
220
+ transport: input.transport.type,
221
+ target: redactedTarget,
222
+ installedFiles: delivery.installedFiles,
223
+ configPath,
224
+ bootstrapUrl: delivery.bootstrapUrl,
225
+ bootstrapExpiresAt: delivery.bootstrapExpiresAt,
226
+ handshake,
227
+ auditId,
228
+ revokeHint,
229
+ next_steps,
230
+ warnings: delivery.warnings,
231
+ };
232
+ }
233
+ function buildHandshakeResult(input, status, waitedMs) {
234
+ if (status === "bootstrap-pending") {
235
+ return {
236
+ verified: false,
237
+ waitedMs: 0,
238
+ reason: "awaiting-redemption — target has not yet consumed the setup token",
239
+ };
240
+ }
241
+ return {
242
+ verified: false,
243
+ waitedMs,
244
+ reason: input.waitForHandshake
245
+ ? "skipped"
246
+ : "waitForHandshake=false",
247
+ };
248
+ }
249
+ /**
250
+ * Poll GET /mcp/certs/:clientName/handshake with exponential backoff
251
+ * until the cert has its first broker handshake or we hit timeoutMs.
252
+ * Delays: 1s, 2s, 4s, 8s, capped at 15s.
253
+ */
254
+ async function pollHandshake(shield, clientName, timeoutMs, startedAt) {
255
+ const deadline = startedAt + timeoutMs;
256
+ let delay = 1000;
257
+ while (Date.now() < deadline) {
258
+ try {
259
+ const h = await shield.getHandshakeStatus(clientName);
260
+ if (h.verified) {
261
+ return {
262
+ verified: true,
263
+ verifiedAt: h.first_handshake_at || new Date().toISOString(),
264
+ receiverUrl: h.receiver_url || undefined,
265
+ waitedMs: Date.now() - startedAt,
266
+ };
267
+ }
268
+ }
269
+ catch (err) {
270
+ // 404 is expected right after issuance in some deployments — keep
271
+ // polling. Other errors we log but don't fatal out; the caller can
272
+ // re-run with waitForHandshake=false if Shield API is broken.
273
+ logger_1.logger.debug("install: handshake poll transient error", {
274
+ clientName, error: err.message,
275
+ });
276
+ }
277
+ const remaining = deadline - Date.now();
278
+ if (remaining <= 0)
279
+ break;
280
+ await new Promise(r => setTimeout(r, Math.min(delay, remaining)));
281
+ delay = Math.min(delay * 2, 15_000);
282
+ }
283
+ return {
284
+ verified: false,
285
+ waitedMs: Date.now() - startedAt,
286
+ reason: `timeout after ${timeoutMs}ms — cert exists but has not yet completed a broker handshake`,
287
+ };
288
+ }
289
+ /**
290
+ * Run install with full lifecycle: advisory lock → audit start →
291
+ * precheck → transport.deliver → handshake poll → audit finalize →
292
+ * lock release. On failure after precheck, the transport's rollback
293
+ * hook runs (SSH rolls back files + revokes the cert; bootstrap-token
294
+ * deletes the minted token).
295
+ *
296
+ * Exposed as the public entry point used by the tool handler in server.ts.
297
+ */
298
+ async function runInstallWithRollback(input, deps) {
299
+ const auditId = (0, crypto_1.randomUUID)();
300
+ let lockAcquired = false;
301
+ // ── 1. Acquire advisory lock (§7 Concurrency) ─────────────────────────
302
+ // Non-fatal if the endpoint is unavailable — log and proceed. Fatal only
303
+ // if Shield reports 409 (another owner holds it).
304
+ try {
305
+ await deps.shield.acquireInstallLock(input.clientName, {
306
+ ttlSeconds: Math.min(Math.max(Math.floor(input.handshakeTimeoutMs / 1000) + 120, 300), 3600),
307
+ auditId,
308
+ });
309
+ lockAcquired = true;
310
+ }
311
+ catch (err) {
312
+ const message = err.message || "";
313
+ if (/409|Conflict|Lock held/i.test(message)) {
314
+ throw new errors_1.InstallError(`Install lock for clientName="${input.clientName}" is held by another operator — wait for them to finish or pick a different clientName.`, { phase: "precheck", clientName: input.clientName, cause: { name: err.name, message } }, 409);
315
+ }
316
+ logger_1.logger.warn("install: lock acquire failed (non-fatal, proceeding)", {
317
+ clientName: input.clientName, error: message,
318
+ });
319
+ }
320
+ // ── 2. Audit: install-started ─────────────────────────────────────────
321
+ void writeAudit(deps.shield, {
322
+ auditId,
323
+ clientName: input.clientName,
324
+ transportType: input.transport.type,
325
+ agentType: input.agentType,
326
+ status: "started",
327
+ phase: "precheck",
328
+ dryRun: input.dryRun,
329
+ target: redactTarget(input.transport),
330
+ });
331
+ let result;
332
+ try {
333
+ result = await runInstall({ ...input }, deps);
334
+ // Override auditId so the tool response matches the audit ledger.
335
+ result.auditId = auditId;
336
+ }
337
+ catch (err) {
338
+ // Audit failure, then re-throw (transport has already rolled back its
339
+ // own state inside deliver()).
340
+ const phase = err instanceof errors_1.InstallError ? err.phase : "precheck";
341
+ void writeAudit(deps.shield, {
342
+ auditId,
343
+ clientName: input.clientName,
344
+ transportType: input.transport.type,
345
+ agentType: input.agentType,
346
+ status: "failed",
347
+ phase,
348
+ dryRun: input.dryRun,
349
+ target: redactTarget(input.transport),
350
+ errorMessage: err.message,
351
+ });
352
+ if (lockAcquired) {
353
+ deps.shield.releaseInstallLock(input.clientName).catch((e) => {
354
+ logger_1.logger.warn("install: lock release after failure errored", {
355
+ clientName: input.clientName, error: e.message,
356
+ });
357
+ });
358
+ }
359
+ if (!input.rollbackOnFailure || !(err instanceof errors_1.InstallError))
360
+ throw err;
361
+ if (err.phase === "precheck" || err.phase === "issue-cert" || err.phase === "mint-token") {
362
+ throw err;
363
+ }
364
+ // The transport has already performed rollback inside its deliver()
365
+ // catch block; we just re-throw unchanged so the caller sees the
366
+ // attached rollback:{performed:true, steps:[...]} block.
367
+ throw err;
368
+ }
369
+ // ── 3. Audit: install completed (or bootstrap-pending) ────────────────
370
+ void writeAudit(deps.shield, {
371
+ auditId,
372
+ clientName: input.clientName,
373
+ transportType: input.transport.type,
374
+ agentType: input.agentType,
375
+ status: result.status,
376
+ phase: result.handshake.verified ? "verify-handshake" : "apply-config",
377
+ dryRun: input.dryRun,
378
+ target: redactTarget(input.transport),
379
+ });
380
+ // ── 4. Release lock ────────────────────────────────────────────────────
381
+ if (lockAcquired) {
382
+ deps.shield.releaseInstallLock(input.clientName).catch((e) => {
383
+ logger_1.logger.warn("install: lock release errored", {
384
+ clientName: input.clientName, error: e.message,
385
+ });
386
+ });
387
+ }
388
+ return result;
389
+ }
390
+ /**
391
+ * Fire-and-forget audit writer — never throws; audit failures are
392
+ * logged but must not break the install flow.
393
+ */
394
+ async function writeAudit(shield, row) {
395
+ try {
396
+ await shield.recordInstallAudit(row);
397
+ }
398
+ catch (err) {
399
+ logger_1.logger.warn("install: audit write failed", {
400
+ auditId: row.auditId, error: err.message,
401
+ });
402
+ }
403
+ }
404
+ //# sourceMappingURL=orchestrator.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * AWS Systems Manager (SSM) transport.
3
+ *
4
+ * Delivers a Shield MCP identity to an SSM-managed EC2 instance without
5
+ * opening inbound ports. Per SHIELD-INSTALL-AGENT-REMOTELY-REQUIREMENTS.md
6
+ * §FR-4, we NEVER embed raw private-key material in the SSM command
7
+ * document. Instead:
8
+ *
9
+ * 1. Mint a short-lived setup token via Shield API.
10
+ * 2. Send an AWS-RunShellScript command to the target that curls the
11
+ * bootstrap URL, writes the bundle to ~/.blacksands/mcp-certs/
12
+ * with correct modes, and (optionally) triggers a restart.
13
+ * 3. Poll GetCommandInvocation for terminal state.
14
+ * 4. On success, return bootstrap-pending-style status; the target's
15
+ * curl invocation is what actually redeems.
16
+ *
17
+ * Private-key transit path: Shield API → target (direct HTTPS). The MCP
18
+ * host never sees the key_pem.
19
+ *
20
+ * @aws-sdk/client-ssm is loaded lazily so bootstrap-token-only users
21
+ * don't pay the SDK cost.
22
+ */
23
+ import type { ShieldClient } from "../../client";
24
+ import type { Transport, TransportDeliveryResult, AwsSsmTransportSpec, AgentType } from "../types";
25
+ export declare class AwsSsmTransport implements Transport {
26
+ private readonly shield;
27
+ private readonly spec;
28
+ readonly type: "aws-ssm";
29
+ constructor(shield: ShieldClient, spec: AwsSsmTransportSpec);
30
+ deliver(args: {
31
+ clientName: string;
32
+ orgId: string;
33
+ agentType: AgentType;
34
+ configPath: string;
35
+ serviceId: string;
36
+ authorizerUrl: string;
37
+ }): Promise<TransportDeliveryResult>;
38
+ rollback(delivery: TransportDeliveryResult): Promise<{
39
+ steps: string[];
40
+ errors: string[];
41
+ }>;
42
+ }
43
+ //# sourceMappingURL=awsSsm.d.ts.map