@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,656 @@
1
+ "use strict";
2
+ /**
3
+ * Shield API Client — Broker mode only.
4
+ *
5
+ * Every request flows through the Blacksands Broker:
6
+ * Step 1: POST {authorizer}/api/agent/auth with { password, serviceId }
7
+ * — the Authorizer validates the mTLS client cert (presented by our
8
+ * httpsAgent), authenticates the password, and resolves the serviceId
9
+ * to a Receiver URL — all in one round trip. Returns a sessionToken
10
+ * plus { service: { serviceUrl } }.
11
+ * Step 2: All subsequent Shield API calls go to service.serviceUrl (the
12
+ * Receiver), which authenticates us and proxies to Shield API.
13
+ *
14
+ * The mTLS client certificate and auth password are issued by a human
15
+ * administrator through Overwatch or SysAdmin (or by the Phase-2 setup-token
16
+ * bootstrap flow, which is just a thin wrapper around the same issuance).
17
+ */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ var __importDefault = (this && this.__importDefault) || function (mod) {
52
+ return (mod && mod.__esModule) ? mod : { "default": mod };
53
+ };
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.ShieldClient = exports.BrokerConnector = void 0;
56
+ const axios_1 = __importDefault(require("axios"));
57
+ const https = __importStar(require("https"));
58
+ const logger_1 = require("../shared/logger");
59
+ const errors_1 = require("../shared/errors");
60
+ const DEFAULT_TIMEOUT = 30_000;
61
+ const DEFAULT_MAX_RETRIES = 3;
62
+ // ── Broker Connector ───────────────────────────────────────────────────
63
+ // Fallback session lifetime when the Authorizer doesn't tell us its lease.
64
+ // Used when talking to a pre-heartbeat-protocol Authorizer.
65
+ const FALLBACK_SESSION_TTL_MS = 15 * 60 * 1000; // 15 min
66
+ // Optional per-handshake lease request. Most clients should leave this unset
67
+ // and let the Authorizer apply its default. Set via the BrokerConnector
68
+ // constructor for tests or workloads that genuinely know their window.
69
+ const DEFAULT_REQUESTED_TTL_SEC = undefined;
70
+ class BrokerConnector {
71
+ authorizerUrl;
72
+ serviceId;
73
+ authPassword;
74
+ httpsAgent;
75
+ sessionToken = null;
76
+ receiverUrl = null;
77
+ connectedAt = 0;
78
+ // Server-granted session lease in ms. Populated from the auth response
79
+ // (`expiresAt` / `leaseSec`); falls back to FALLBACK_SESSION_TTL_MS when
80
+ // talking to a pre-heartbeat Authorizer that doesn't return them.
81
+ sessionTtlMs = FALLBACK_SESSION_TTL_MS;
82
+ heartbeatTimer = null;
83
+ requestedTtlSec;
84
+ constructor(authorizerUrl, serviceId, authPassword, httpsAgent, requestedTtlSec = DEFAULT_REQUESTED_TTL_SEC) {
85
+ this.authorizerUrl = authorizerUrl.replace(/\/+$/, "");
86
+ this.serviceId = serviceId;
87
+ this.authPassword = authPassword;
88
+ this.httpsAgent = httpsAgent;
89
+ this.requestedTtlSec = requestedTtlSec;
90
+ }
91
+ get isConnected() {
92
+ return !!(this.receiverUrl && (Date.now() - this.connectedAt) < this.sessionTtlMs);
93
+ }
94
+ get url() {
95
+ if (!this.receiverUrl)
96
+ throw new errors_1.ShieldError("BrokerConnector: not connected — call connect() first");
97
+ return this.receiverUrl;
98
+ }
99
+ async connect() {
100
+ logger_1.logger.info("BrokerConnector: starting Authorizer handshake", {
101
+ authorizer: this.authorizerUrl,
102
+ serviceId: this.serviceId,
103
+ });
104
+ // Single-call handshake against the real Authorizer contract:
105
+ // POST /api/agent/auth
106
+ // mTLS cert is presented by httpsAgent; the Authorizer validates it
107
+ // against the Manager, then authenticates the password, then (because
108
+ // we passed serviceId) resolves the Receiver URL — all one round trip.
109
+ //
110
+ // Response shape (on success):
111
+ // { success: true, authenticated: true,
112
+ // sessionToken: "<uuid>",
113
+ // identity: { username, certFingerprint, isAgent },
114
+ // service: { serviceId, serviceUrl, serviceType },
115
+ // leaseSec, expiresAt } // ← new
116
+ //
117
+ // leaseSec/expiresAt are absent on older Authorizers; we fall back to
118
+ // FALLBACK_SESSION_TTL_MS in that case.
119
+ //
120
+ // On failure: non-2xx with { error: "<message>" }. axios will throw,
121
+ // which we surface as AuthenticationError / ShieldError.
122
+ let authRes;
123
+ try {
124
+ const reqBody = {
125
+ password: this.authPassword,
126
+ serviceId: this.serviceId,
127
+ };
128
+ if (this.requestedTtlSec)
129
+ reqBody.requestedTtlSec = this.requestedTtlSec;
130
+ authRes = await axios_1.default.post(`${this.authorizerUrl}/api/agent/auth`, reqBody, { httpsAgent: this.httpsAgent, timeout: 30_000 });
131
+ }
132
+ catch (err) {
133
+ const status = err?.response?.status;
134
+ const body = err?.response?.data;
135
+ const msg = body?.error || body?.message || err?.message || "unknown error";
136
+ if (status === 401)
137
+ throw new errors_1.AuthenticationError(`BrokerConnector: ${msg}`);
138
+ throw new errors_1.ShieldError(`BrokerConnector: Authorizer handshake failed (${status ?? "no response"}): ${msg}`);
139
+ }
140
+ const data = authRes.data || {};
141
+ if (!data.success || !data.authenticated || !data.sessionToken) {
142
+ throw new errors_1.AuthenticationError(`BrokerConnector: Authorizer rejected handshake: ${data.error || data.message || "no sessionToken returned"}`);
143
+ }
144
+ this.sessionToken = data.sessionToken;
145
+ const serviceUrl = data.service?.serviceUrl;
146
+ if (!serviceUrl) {
147
+ throw new errors_1.ShieldError(`BrokerConnector: Authorizer did not resolve a Receiver URL for serviceId="${this.serviceId}"`);
148
+ }
149
+ this.receiverUrl = serviceUrl;
150
+ this.connectedAt = Date.now();
151
+ // Use the server-granted lease if present, otherwise fall back. We track
152
+ // the lease in ms locally so isConnected matches the Authorizer's view.
153
+ if (typeof data.leaseSec === "number" && data.leaseSec > 0) {
154
+ this.sessionTtlMs = data.leaseSec * 1000;
155
+ }
156
+ else {
157
+ this.sessionTtlMs = FALLBACK_SESSION_TTL_MS;
158
+ }
159
+ logger_1.logger.info("BrokerConnector: handshake complete", {
160
+ sessionToken: this.sessionToken.substring(0, 8) + "...",
161
+ username: data.identity?.username,
162
+ receiverUrl: this.receiverUrl,
163
+ leaseSec: Math.round(this.sessionTtlMs / 1000),
164
+ });
165
+ this.startHeartbeat();
166
+ return this.receiverUrl;
167
+ }
168
+ async ensureConnected() {
169
+ if (!this.isConnected) {
170
+ logger_1.logger.info("BrokerConnector: session expired or not connected — reconnecting");
171
+ await this.connect();
172
+ }
173
+ }
174
+ async reconnect() {
175
+ this.stopHeartbeat();
176
+ this.sessionToken = null;
177
+ this.receiverUrl = null;
178
+ this.connectedAt = 0;
179
+ return this.connect();
180
+ }
181
+ /**
182
+ * Tear down state and stop the heartbeat. Tests/teardown call this; runtime
183
+ * normally stays connected for the process lifetime.
184
+ */
185
+ close() {
186
+ this.stopHeartbeat();
187
+ this.sessionToken = null;
188
+ this.receiverUrl = null;
189
+ this.connectedAt = 0;
190
+ }
191
+ // ── Heartbeat ─────────────────────────────────────────────────────────
192
+ //
193
+ // After a successful handshake, fire a POST /api/agent/heartbeat at
194
+ // lease/3 cadence. Each tick extends the Authorizer's Redis session by
195
+ // the original lease, keeping the Receiver-side service-authorization
196
+ // table fresh. If the heartbeat fails twice in a row we drop the local
197
+ // session state so the next tool call forces a fresh handshake — better
198
+ // than letting tool calls hit a 401 we already knew was coming.
199
+ startHeartbeat() {
200
+ this.stopHeartbeat();
201
+ // Pick lease/3 as the cadence; clamp to [10s, 5min] so we don't burn the
202
+ // network on tiny leases or sleep through long ones.
203
+ const intervalMs = Math.min(Math.max(Math.floor(this.sessionTtlMs / 3), 10_000), 5 * 60_000);
204
+ let consecutiveFailures = 0;
205
+ this.heartbeatTimer = setInterval(async () => {
206
+ if (!this.sessionToken) {
207
+ this.stopHeartbeat();
208
+ return;
209
+ }
210
+ try {
211
+ await axios_1.default.post(`${this.authorizerUrl}/api/agent/heartbeat`, { sessionToken: this.sessionToken }, { httpsAgent: this.httpsAgent, timeout: 10_000 });
212
+ consecutiveFailures = 0;
213
+ // Slide our local clock forward so isConnected stays true. We don't
214
+ // strictly need to read the response's expiresAt; the Authorizer
215
+ // applies the same lease deterministically.
216
+ this.connectedAt = Date.now();
217
+ }
218
+ catch (err) {
219
+ const status = err?.response?.status;
220
+ // 404 = server has no heartbeat endpoint (older Authorizer). Stop
221
+ // heartbeating quietly; rely on the timer-based reconnect path.
222
+ if (status === 404) {
223
+ logger_1.logger.info("BrokerConnector: Authorizer has no /heartbeat endpoint, falling back to timer-based reconnect");
224
+ this.stopHeartbeat();
225
+ return;
226
+ }
227
+ consecutiveFailures += 1;
228
+ logger_1.logger.warn("BrokerConnector: heartbeat failed", {
229
+ status, error: err?.message, consecutiveFailures,
230
+ });
231
+ if (consecutiveFailures >= 2) {
232
+ // Two misses in a row — give up, drop state so next tool call
233
+ // re-handshakes. The cert+password are still valid; this is just
234
+ // belt-and-suspenders against a wedged session.
235
+ this.stopHeartbeat();
236
+ this.sessionToken = null;
237
+ this.receiverUrl = null;
238
+ this.connectedAt = 0;
239
+ }
240
+ }
241
+ }, intervalMs);
242
+ // Don't keep the Node event loop alive just for heartbeats.
243
+ if (typeof this.heartbeatTimer.unref === "function")
244
+ this.heartbeatTimer.unref();
245
+ }
246
+ stopHeartbeat() {
247
+ if (this.heartbeatTimer) {
248
+ clearInterval(this.heartbeatTimer);
249
+ this.heartbeatTimer = null;
250
+ }
251
+ }
252
+ }
253
+ exports.BrokerConnector = BrokerConnector;
254
+ // ── Shield Client ──────────────────────────────────────────────────────
255
+ class ShieldClient {
256
+ http;
257
+ maxRetries;
258
+ broker = null;
259
+ httpsAgent;
260
+ options;
261
+ mode;
262
+ constructor(options) {
263
+ if (!options.broker && !options.directUrl) {
264
+ throw new errors_1.ShieldError("ShieldClient requires either `broker` (stdio mode) or `directUrl` (http-service mode)");
265
+ }
266
+ if (options.broker && options.directUrl) {
267
+ throw new errors_1.ShieldError("ShieldClient: `broker` and `directUrl` are mutually exclusive");
268
+ }
269
+ this.options = options;
270
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
271
+ this.mode = options.broker ? "broker" : "direct";
272
+ this.httpsAgent = new https.Agent({
273
+ cert: options.clientCert,
274
+ key: options.clientKey,
275
+ ...(options.caCert ? { ca: options.caCert } : {}),
276
+ rejectUnauthorized: true,
277
+ });
278
+ if (this.mode === "broker") {
279
+ this.broker = new BrokerConnector(options.broker.authorizerUrl, options.broker.serviceId, options.broker.authPassword, this.httpsAgent);
280
+ // Placeholder; replaced after connect() resolves the Receiver URL.
281
+ this.http = axios_1.default.create({
282
+ baseURL: "http://placeholder-will-be-replaced",
283
+ timeout: options.timeout ?? DEFAULT_TIMEOUT,
284
+ headers: { "Content-Type": "application/json" },
285
+ });
286
+ logger_1.logger.info("ShieldClient: broker mode — will connect through Authorizer → Receiver", {
287
+ authorizer: options.broker.authorizerUrl,
288
+ serviceId: options.broker.serviceId,
289
+ });
290
+ }
291
+ else {
292
+ // Direct mode: talk to Shield API with a service mTLS cert.
293
+ this.http = axios_1.default.create({
294
+ baseURL: options.directUrl,
295
+ timeout: options.timeout ?? DEFAULT_TIMEOUT,
296
+ httpsAgent: this.httpsAgent,
297
+ headers: { "Content-Type": "application/json" },
298
+ });
299
+ logger_1.logger.info("ShieldClient: direct service mode — mTLS to Shield API", { apiUrl: options.directUrl });
300
+ }
301
+ }
302
+ /** Complete the Broker handshake. No-op in direct mode. */
303
+ async connect() {
304
+ if (this.mode === "direct")
305
+ return;
306
+ const receiverUrl = await this.broker.connect();
307
+ // Shield API mounts every public route under /v1 (see
308
+ // overview/shield-api/routes/index.js line 153: `app.use('/v1', v1)`).
309
+ // The Receiver forwards paths as-is, so the tool calls below (which use
310
+ // "/mcp/certs", "/orgs", etc. without a /v1 prefix) need the /v1 here,
311
+ // not sprinkled on every callsite.
312
+ //
313
+ // Strip the ?token=... query string the Authorizer appends before
314
+ // constructing the baseURL — the axios path e.g. "/orgs" would otherwise
315
+ // be appended after the query string, producing an invalid URL like
316
+ // "https://host?token=abc/v1/orgs" instead of "https://host/v1/orgs".
317
+ //
318
+ // We extract the token separately and pass it as a default axios `params`
319
+ // entry so every request reaches the Receiver as e.g. GET /v1/orgs?token=...
320
+ // This lets the Receiver's Lua authenticate via its token→serviceId Redis
321
+ // lookup, which is populated by the service_authorization Kafka message sent
322
+ // immediately before the Manager returns the serviceUrl. Relying solely on
323
+ // the certificate fingerprint lookup can race — the Kafka consumer on the
324
+ // Receiver may not have written the Redis key yet when the first request
325
+ // arrives. The token path is the primary auth path for this flow.
326
+ const [receiverBase, receiverQs] = receiverUrl.split("?");
327
+ const accessToken = receiverQs ? new URLSearchParams(receiverQs).get("token") : null;
328
+ const baseURL = `${receiverBase.replace(/\/+$/, "")}/v1`;
329
+ this.http = axios_1.default.create({
330
+ baseURL,
331
+ timeout: this.options.timeout ?? DEFAULT_TIMEOUT,
332
+ headers: { "Content-Type": "application/json" },
333
+ httpsAgent: this.httpsAgent,
334
+ ...(accessToken ? { params: { token: accessToken } } : {}),
335
+ });
336
+ logger_1.logger.info("ShieldClient: connected via Broker — all API calls routed through Receiver", {
337
+ receiverUrl,
338
+ baseURL,
339
+ hasToken: !!accessToken,
340
+ });
341
+ }
342
+ get authMethod() {
343
+ return this.mode;
344
+ }
345
+ // ── Generic request with retry ──────────────────────────────────────────
346
+ async request(method, path, data) {
347
+ if (this.broker)
348
+ await this.broker.ensureConnected();
349
+ let lastError = null;
350
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
351
+ try {
352
+ const response = await this.http.request({ method, url: path, data });
353
+ return response.data;
354
+ }
355
+ catch (err) {
356
+ const status = err.response?.status;
357
+ // Broker-only: 401/403 from the Receiver requires special handling.
358
+ //
359
+ // 403 on attempt 1 is most likely a race condition: the Manager sends
360
+ // the service_authorization Kafka message and immediately returns the
361
+ // serviceUrl; the Receiver's Kafka consumer may not have written the
362
+ // token→serviceId Redis key before our first request arrives. Wait
363
+ // 1 s and retry with the same session — the Redis key will be there.
364
+ //
365
+ // 401 on attempt 1 means the Receiver actively rejected our cert/token,
366
+ // which warrants an immediate reconnect (fresh handshake + new token).
367
+ //
368
+ // Any failure on attempt 2+ → propagate as AuthenticationError.
369
+ if (this.broker && attempt === 1) {
370
+ if (status === 403) {
371
+ logger_1.logger.warn("BrokerConnector: 403 from Receiver on attempt 1 — waiting for session Redis propagation, retrying", { path });
372
+ await new Promise(r => setTimeout(r, 1000));
373
+ continue; // retry with same session/token
374
+ }
375
+ if (status === 401) {
376
+ logger_1.logger.warn("BrokerConnector: 401 from Receiver — reconnecting", { path });
377
+ const newUrl = await this.broker.reconnect();
378
+ // Keep the /v1 suffix and token param in sync with connect().
379
+ const [newBase, newQs] = newUrl.split("?");
380
+ const newToken = newQs ? new URLSearchParams(newQs).get("token") : null;
381
+ this.http = axios_1.default.create({
382
+ baseURL: `${newBase.replace(/\/+$/, "")}/v1`,
383
+ timeout: this.options.timeout ?? DEFAULT_TIMEOUT,
384
+ headers: { "Content-Type": "application/json" },
385
+ httpsAgent: this.httpsAgent,
386
+ ...(newToken ? { params: { token: newToken } } : {}),
387
+ });
388
+ continue;
389
+ }
390
+ }
391
+ if (status === 401 || status === 403)
392
+ throw new errors_1.AuthenticationError(err.response?.data?.message);
393
+ if (status === 404)
394
+ throw new errors_1.NotFoundError("Resource", path);
395
+ if (status === 429)
396
+ throw new errors_1.RateLimitError(err.response?.headers?.["retry-after"]);
397
+ if (status && status >= 400 && status < 500)
398
+ throw new errors_1.ShieldError(err.response?.data?.message || err.message, status);
399
+ lastError = err;
400
+ if (attempt < this.maxRetries) {
401
+ const delay = 2 ** attempt * 1000;
402
+ logger_1.logger.debug(`Retry ${attempt}/${this.maxRetries} in ${delay}ms`, { path });
403
+ await new Promise(r => setTimeout(r, delay));
404
+ }
405
+ }
406
+ }
407
+ throw lastError || new errors_1.ShieldError("Request failed after retries");
408
+ }
409
+ // ── Operation polling ───────────────────────────────────────────────────
410
+ async pollOperation(operationId, opts = {}) {
411
+ const interval = opts.interval ?? 2000;
412
+ const timeout = opts.timeout ?? 120000;
413
+ const start = Date.now();
414
+ while (Date.now() - start < timeout) {
415
+ const op = await this.request("GET", `/operations/${operationId}`);
416
+ if (op.status === "completed")
417
+ return op;
418
+ if (op.status === "failed")
419
+ throw new errors_1.ShieldError(op.error || "Operation failed", 500, op);
420
+ await new Promise(r => setTimeout(r, interval));
421
+ }
422
+ throw new errors_1.ShieldError(`Operation ${operationId} timed out after ${timeout}ms`);
423
+ }
424
+ // ── MCP Client Certificates (read-only surface) ─────────────────────────
425
+ // Certificate issuance is an administrator-only action performed through
426
+ // Overwatch or SysAdmin. The Agent can list and revoke, not issue.
427
+ //
428
+ // `issueMcpCert` and `mintSetupToken` are deliberately NOT exposed as MCP
429
+ // tools — they are lower-level primitives used ONLY by the composite
430
+ // `bursar_install_agent_remotely` orchestration tool. Exposing them as
431
+ // standalone tools would defeat the "agents can't mint credentials
432
+ // unscoped" invariant. If you are tempted to wire either of these into a
433
+ // new `server.tool(...)` registration, stop and read the commit that
434
+ // removed `bursar_issue_mcp_cert` first.
435
+ async listMcpCerts() {
436
+ return this.request("GET", "/mcp/certs");
437
+ }
438
+ async revokeMcpCert(clientName) {
439
+ return this.request("DELETE", `/mcp/certs/${clientName}`);
440
+ }
441
+ /**
442
+ * Issue a full MCP client certificate bundle. Internal use only —
443
+ * consumed by bursar_install_agent_remotely's SSH and aws-ssm transports,
444
+ * which need the key material in-process to ship to the target.
445
+ *
446
+ * The returned `key_pem` is SENSITIVE: callers MUST treat it as tainted
447
+ * and MUST NOT persist it. See bursar_install_agent_remotely §FR-1.
448
+ */
449
+ async issueMcpCert(clientName, orgId) {
450
+ return this.request("POST", "/mcp/certs", { clientName, orgId });
451
+ }
452
+ /**
453
+ * Mint a short-lived single-use setup token bound to {clientName, orgId}.
454
+ * Consumed by bursar_install_agent_remotely's bootstrap-token transport —
455
+ * the token is handed to the target, which calls POST /mcp/setup-tokens/redeem
456
+ * to exchange it for a cert bundle. The private key is returned to the
457
+ * redeemer; the MCP host holding this call never sees it.
458
+ *
459
+ * `expiresIn` accepts a number of seconds or a shorthand like "1h"/"30m".
460
+ *
461
+ * `role` ('master' | 'consumer') stamps the role on the token row so the
462
+ * redeem flow propagates it to the issued McpClient.role, and the redeem
463
+ * response echoes it back so setup.js can write
464
+ * ~/.blacksands/mcp-certs/.role for boot-time tool filtering. Defaults
465
+ * to 'master' on the server side.
466
+ */
467
+ async mintSetupToken(clientName, orgId, expiresIn = "1h", role) {
468
+ const body = { clientName, orgId, expiresIn };
469
+ if (role)
470
+ body.role = role;
471
+ return this.request("POST", "/mcp/setup-tokens", body);
472
+ }
473
+ /**
474
+ * Revoke a pending (unconsumed) setup token by ID. Internal rollback
475
+ * helper for bursar_install_agent_remotely — NOT exposed as a tool.
476
+ * A 404 from the server means the token was already consumed or
477
+ * expired; callers should treat that as success for rollback purposes.
478
+ */
479
+ async revokeSetupToken(tokenId) {
480
+ return this.request("DELETE", `/mcp/setup-tokens/${encodeURIComponent(tokenId)}`);
481
+ }
482
+ // ── Install-tool support (bursar_install_agent_remotely only) ──────────
483
+ // These are rollback/verification primitives. Not exposed as tools.
484
+ /**
485
+ * Check whether the named MCP client has completed its first broker
486
+ * handshake since issuance. Polled by the install orchestrator with
487
+ * exponential backoff until waitForHandshake timeout.
488
+ */
489
+ async getHandshakeStatus(clientName) {
490
+ return this.request("GET", `/mcp/certs/${encodeURIComponent(clientName)}/handshake`);
491
+ }
492
+ /**
493
+ * Acquire the per-clientName advisory lock. Throws on 409 (conflict).
494
+ */
495
+ async acquireInstallLock(clientName, opts = {}) {
496
+ return this.request("POST", `/mcp/certs/${encodeURIComponent(clientName)}/lock`, opts);
497
+ }
498
+ /** Release the advisory lock. Owner-match enforced server-side. */
499
+ async releaseInstallLock(clientName) {
500
+ return this.request("DELETE", `/mcp/certs/${encodeURIComponent(clientName)}/lock`);
501
+ }
502
+ /** Append an audit row for a bursar_install_agent_remotely invocation. */
503
+ async recordInstallAudit(row) {
504
+ return this.request("POST", "/audit/agent-installs", row);
505
+ }
506
+ // ── Organizations ───────────────────────────────────────────────────────
507
+ async createOrg(name, plan) {
508
+ return this.request("POST", "/orgs", { name, plan });
509
+ }
510
+ async getOrg(orgId) {
511
+ return this.request("GET", `/orgs/${orgId}`);
512
+ }
513
+ async listOrgs(page = 1, pageSize = 20) {
514
+ return this.request("GET", `/orgs?page=${page}&pageSize=${pageSize}`);
515
+ }
516
+ // ── Applications ────────────────────────────────────────────────────────
517
+ async createApp(orgId, name, opts) {
518
+ // Strip undefined so we don't send empty optional fields the validator may reject.
519
+ const body = { name };
520
+ for (const [k, v] of Object.entries(opts ?? {})) {
521
+ if (v !== undefined)
522
+ body[k] = v;
523
+ }
524
+ return this.request("POST", `/orgs/${orgId}/apps`, body);
525
+ }
526
+ async getApp(appId) {
527
+ return this.request("GET", `/apps/${appId}`);
528
+ }
529
+ async listApps(orgId) {
530
+ return this.request("GET", `/orgs/${orgId}/apps`);
531
+ }
532
+ // ── Manifests ───────────────────────────────────────────────────────────
533
+ async submitManifest(manifest, orgId) {
534
+ return this.request("POST", "/manifests", { manifest, orgId });
535
+ }
536
+ async getManifest(manifestId) {
537
+ return this.request("GET", `/manifests/${manifestId}`);
538
+ }
539
+ async provisionManifest(manifestId) {
540
+ return this.request("POST", `/manifests/${manifestId}/provision`);
541
+ }
542
+ // ── Certificates ────────────────────────────────────────────────────────
543
+ async listCerts(appId) {
544
+ return this.request("GET", `/apps/${appId}/certs`);
545
+ }
546
+ async rotateCert(appId, certId) {
547
+ return this.request("POST", `/apps/${appId}/certs/${certId}/rotate`);
548
+ }
549
+ async revokeCert(appId, certId, reason) {
550
+ return this.request("POST", `/apps/${appId}/certs/${certId}/revoke`, { reason });
551
+ }
552
+ // ── Endpoints ───────────────────────────────────────────────────────────
553
+ async listEndpoints(appId) {
554
+ return this.request("GET", `/apps/${appId}/endpoints`);
555
+ }
556
+ // ── Policies ────────────────────────────────────────────────────────────
557
+ async listPolicies(appId) {
558
+ return this.request("GET", `/apps/${appId}/policies`);
559
+ }
560
+ async createPolicy(appId, name, type, rules) {
561
+ return this.request("POST", `/apps/${appId}/policies`, { name, type, rules });
562
+ }
563
+ // ── DNS (application-level allow/block) ─────────────────────────────────
564
+ async updateDnsRules(appId, rules) {
565
+ return this.request("POST", `/apps/${appId}/dns`, { rules });
566
+ }
567
+ // ── Verification ────────────────────────────────────────────────────────
568
+ async triggerVerify(appId,
569
+ // Required by the API route's validator; the tool defaults it to 'full'.
570
+ scanType = "full") {
571
+ return this.request("POST", "/verify", { appId, scanType });
572
+ }
573
+ async getLatestPosture(appId) {
574
+ return this.request("GET", `/verify/${appId}/latest`);
575
+ }
576
+ // ── Compliance ──────────────────────────────────────────────────────────
577
+ async getComplianceReport(appId, framework) {
578
+ return this.request("GET", `/apps/${appId}/compliance?framework=${framework}`);
579
+ }
580
+ async getComplianceControls(framework) {
581
+ return this.request("GET", `/compliance/controls/${framework}`);
582
+ }
583
+ // ── Sessions ────────────────────────────────────────────────────────────
584
+ async listSessions(appId) {
585
+ return this.request("GET", `/apps/${appId}/sessions`);
586
+ }
587
+ async revokeSession(appId, sessionId) {
588
+ return this.request("POST", `/apps/${appId}/sessions/${sessionId}/revoke`);
589
+ }
590
+ // ── Lockdown ────────────────────────────────────────────────────────────
591
+ async emergencyLockdown(appId, reason) {
592
+ return this.request("POST", `/apps/${appId}/lockdown`, { reason });
593
+ }
594
+ async liftLockdown(appId) {
595
+ return this.request("DELETE", `/apps/${appId}/lockdown`);
596
+ }
597
+ // ── Receiver read access (public admin surface) ─────────────────────────
598
+ async getReceiver(receiverUID) {
599
+ return this.request("GET", `/mcp/receivers/${receiverUID}`);
600
+ }
601
+ async listReceivers(filters) {
602
+ const params = new URLSearchParams();
603
+ if (filters?.status)
604
+ params.set("status", filters.status);
605
+ if (filters?.organizationId)
606
+ params.set("organizationId", filters.organizationId);
607
+ const qs = params.toString();
608
+ return this.request("GET", `/mcp/receivers${qs ? "?" + qs : ""}`);
609
+ }
610
+ async initializeReceiver(installerEmail, organizationId, notes) {
611
+ return this.request("POST", `/mcp/receivers/initialize`, { installerEmail, organizationId, notes });
612
+ }
613
+ async getReceiverStatus(receiverUID) {
614
+ return this.request("GET", `/mcp/receivers/${receiverUID}/status`);
615
+ }
616
+ async activateReceiver(receiverUID) {
617
+ return this.request("POST", `/mcp/receivers/${receiverUID}/activate`);
618
+ }
619
+ async resendReceiverEmail(receiverUID) {
620
+ return this.request("POST", `/mcp/receivers/${receiverUID}/resend-email`);
621
+ }
622
+ async cancelReceiverInit(receiverUID) {
623
+ return this.request("POST", `/mcp/receivers/${receiverUID}/cancel`);
624
+ }
625
+ async getReceiverStatistics() {
626
+ return this.request("GET", `/mcp/receivers/statistics`);
627
+ }
628
+ // ── Receiver services (networking-only onboarding surface) ──────────────
629
+ async onboardReceiverService(receiverUID, svc) {
630
+ return this.request("POST", `/mcp/receivers/${receiverUID}/services`, svc);
631
+ }
632
+ async removeReceiverService(receiverUID, serviceName) {
633
+ return this.request("DELETE", `/mcp/receivers/${receiverUID}/services/${encodeURIComponent(serviceName)}`);
634
+ }
635
+ async listReceiverServices(receiverUID) {
636
+ return this.request("GET", `/mcp/receivers/${receiverUID}/services`);
637
+ }
638
+ // ── Service lifecycle (F4.12) ───────────────────────────────────────────
639
+ // pause/resume/disable/status for a receiver-proxied service. pause is a
640
+ // reversible stop (registration kept); disable is a stronger off-state that
641
+ // an admin must re-enable; remove (above) deletes the registration entirely.
642
+ async pauseService(receiverUID, serviceName) {
643
+ return this.request("POST", `/mcp/receivers/${receiverUID}/services/${encodeURIComponent(serviceName)}/pause`);
644
+ }
645
+ async resumeService(receiverUID, serviceName) {
646
+ return this.request("POST", `/mcp/receivers/${receiverUID}/services/${encodeURIComponent(serviceName)}/resume`);
647
+ }
648
+ async disableService(receiverUID, serviceName) {
649
+ return this.request("POST", `/mcp/receivers/${receiverUID}/services/${encodeURIComponent(serviceName)}/disable`);
650
+ }
651
+ async getServiceStatus(receiverUID, serviceName) {
652
+ return this.request("GET", `/mcp/receivers/${receiverUID}/services/${encodeURIComponent(serviceName)}/status`);
653
+ }
654
+ }
655
+ exports.ShieldClient = ShieldClient;
656
+ //# sourceMappingURL=client.js.map