@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,50 @@
1
+ /** MCP-compatible error types. */
2
+ export declare class ShieldError extends Error {
3
+ code: number;
4
+ details?: unknown | undefined;
5
+ constructor(message: string, code?: number, details?: unknown | undefined);
6
+ }
7
+ export declare class AuthenticationError extends ShieldError {
8
+ constructor(message?: string);
9
+ }
10
+ export declare class NotFoundError extends ShieldError {
11
+ constructor(resource: string, id: string);
12
+ }
13
+ export declare class ValidationError extends ShieldError {
14
+ constructor(message: string, details?: unknown);
15
+ }
16
+ export declare class RateLimitError extends ShieldError {
17
+ constructor(retryAfter?: number);
18
+ }
19
+ /**
20
+ * Phases of `bursar_install_agent_remotely` that can fail. The phase name
21
+ * is included in every InstallError so callers know where rollback starts.
22
+ */
23
+ export type InstallPhase = "issue-cert" | "mint-token" | "connect-transport" | "write-files" | "apply-config" | "restart-agent" | "verify-handshake" | "rollback" | "precheck";
24
+ export interface InstallErrorDetails {
25
+ phase: InstallPhase;
26
+ clientName?: string;
27
+ transport?: "ssh" | "bootstrap-token" | "aws-ssm";
28
+ next_steps?: string[];
29
+ rollback?: {
30
+ performed: boolean;
31
+ steps: string[];
32
+ errors?: string[];
33
+ };
34
+ cause?: {
35
+ name: string;
36
+ message: string;
37
+ };
38
+ }
39
+ /**
40
+ * Thrown by `bursar_install_agent_remotely`. `phase` tells callers which
41
+ * stage of the install failed; `next_steps` gives the operator concrete
42
+ * remediation to surface up to the calling agent.
43
+ */
44
+ export declare class InstallError extends ShieldError {
45
+ readonly phase: InstallPhase;
46
+ constructor(message: string, details: InstallErrorDetails, code?: number);
47
+ }
48
+ /** Format any error into MCP tool result text. */
49
+ export declare function formatError(err: unknown): string;
50
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /** MCP-compatible error types. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InstallError = exports.RateLimitError = exports.ValidationError = exports.NotFoundError = exports.AuthenticationError = exports.ShieldError = void 0;
5
+ exports.formatError = formatError;
6
+ class ShieldError extends Error {
7
+ code;
8
+ details;
9
+ constructor(message, code = 500, details) {
10
+ super(message);
11
+ this.code = code;
12
+ this.details = details;
13
+ this.name = "ShieldError";
14
+ }
15
+ }
16
+ exports.ShieldError = ShieldError;
17
+ class AuthenticationError extends ShieldError {
18
+ constructor(message = "Authentication failed") {
19
+ super(message, 401);
20
+ this.name = "AuthenticationError";
21
+ }
22
+ }
23
+ exports.AuthenticationError = AuthenticationError;
24
+ class NotFoundError extends ShieldError {
25
+ constructor(resource, id) {
26
+ super(`${resource} '${id}' not found`, 404);
27
+ this.name = "NotFoundError";
28
+ }
29
+ }
30
+ exports.NotFoundError = NotFoundError;
31
+ class ValidationError extends ShieldError {
32
+ constructor(message, details) {
33
+ super(message, 400, details);
34
+ this.name = "ValidationError";
35
+ }
36
+ }
37
+ exports.ValidationError = ValidationError;
38
+ class RateLimitError extends ShieldError {
39
+ constructor(retryAfter) {
40
+ super(`Rate limited${retryAfter ? `, retry after ${retryAfter}s` : ""}`, 429);
41
+ this.name = "RateLimitError";
42
+ }
43
+ }
44
+ exports.RateLimitError = RateLimitError;
45
+ /**
46
+ * Thrown by `bursar_install_agent_remotely`. `phase` tells callers which
47
+ * stage of the install failed; `next_steps` gives the operator concrete
48
+ * remediation to surface up to the calling agent.
49
+ */
50
+ class InstallError extends ShieldError {
51
+ phase;
52
+ constructor(message, details, code = 500) {
53
+ super(message, code, details);
54
+ this.name = "InstallError";
55
+ this.phase = details.phase;
56
+ }
57
+ }
58
+ exports.InstallError = InstallError;
59
+ /** Format any error into MCP tool result text. */
60
+ function formatError(err) {
61
+ if (err instanceof ShieldError) {
62
+ return JSON.stringify({ error: err.name, message: err.message, code: err.code, details: err.details });
63
+ }
64
+ if (err instanceof Error) {
65
+ return JSON.stringify({ error: "InternalError", message: err.message });
66
+ }
67
+ return JSON.stringify({ error: "UnknownError", message: String(err) });
68
+ }
69
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * linkBuilder — Shield Portal deep links for tool results (U1, Phase S).
3
+ *
4
+ * Tools that produce a posture-relevant result append a single markdown line:
5
+ *
6
+ * "Posture preview: 62/100 — view full report → https://shield…/r/<token>"
7
+ *
8
+ * Two flavors:
9
+ * - Account-bound tools (bursar_get_posture, bursar_provision_app,
10
+ * bursar_compliance_report, …) link straight to the portal.
11
+ * - The FREE local scan (bursar_scan_codebase with no account) POSTs its
12
+ * ANONYMIZED summary to the public Shield API claim endpoint
13
+ * (POST /v1/claims) and links to the returned claim URL. If the API is
14
+ * unreachable/offline the scan result is returned unchanged — no link,
15
+ * no error. The golden path must never make the free tool worse.
16
+ *
17
+ * Env:
18
+ * SHIELD_PORTAL_URL portal base (default https://shield.beta.blacksandscyber.online)
19
+ * SHIELD_CLAIM_API_URL Shield API base for POST /v1/claims (default = portal host's API;
20
+ * when unset we fall back to SHIELD_API_URL, then the beta API URL)
21
+ * SHIELD_CLAIM_DISABLED set to "true" to suppress claim posting entirely
22
+ */
23
+ export declare const DEFAULT_PORTAL_URL = "https://shield.beta.blacksandscyber.online";
24
+ export declare const DEFAULT_CLAIM_API_URL = "https://shield-api.beta.blacksandscyber.online";
25
+ /** Portal base URL, env-overridable, no trailing slash. */
26
+ export declare function portalBaseUrl(env?: NodeJS.ProcessEnv): string;
27
+ /** Shield API base used for the public claim POST, no trailing slash. */
28
+ export declare function claimApiBaseUrl(env?: NodeJS.ProcessEnv): string;
29
+ /** Build a portal URL for a path ('/', '/r/<token>', …). */
30
+ export declare function buildPortalUrl(path: string, env?: NodeJS.ProcessEnv): string;
31
+ /**
32
+ * The one-line markdown footer. Score is clamped to 0–100; pass null/undefined
33
+ * to omit the numeric preview ("View full report → …").
34
+ */
35
+ export declare function postureLine(score: number | null | undefined, url: string): string;
36
+ /**
37
+ * Derive a preview score from a severity summary (free local scan has no
38
+ * server-computed posture). Deliberately simple and stable: start at 100,
39
+ * subtract per finding by severity, clamp to [5, 100] — a project with
40
+ * findings never shows 0 (demoralizing) or 100 (wrong).
41
+ */
42
+ export declare function previewScoreFromSeverity(bySeverity: Partial<Record<"critical" | "high" | "medium" | "low" | "info", number>> | null | undefined): number;
43
+ export interface ClaimSummaryInput {
44
+ score: number;
45
+ highestSeverity?: string;
46
+ bySeverity?: Record<string, number>;
47
+ framework?: {
48
+ name?: string;
49
+ language?: string;
50
+ packageManager?: string;
51
+ };
52
+ counts?: {
53
+ endpoints?: number;
54
+ piiFields?: number;
55
+ externalServices?: number;
56
+ dataStores?: number;
57
+ };
58
+ findings?: Array<{
59
+ title: string;
60
+ severity?: string;
61
+ category?: string;
62
+ recommendation?: string;
63
+ }>;
64
+ source?: string;
65
+ }
66
+ export interface ClaimResult {
67
+ claimUrl: string;
68
+ claimToken: string;
69
+ score: number;
70
+ expiresAt?: string;
71
+ }
72
+ /**
73
+ * POST the anonymized summary to the public claim endpoint. Returns the claim
74
+ * URL on success, or null on ANY failure (offline, non-2xx, timeout, bad
75
+ * JSON) — callers degrade gracefully to "no link". Never throws.
76
+ */
77
+ export declare function postClaimSummary(summary: ClaimSummaryInput, opts?: {
78
+ env?: NodeJS.ProcessEnv;
79
+ fetchImpl?: typeof fetch;
80
+ timeoutMs?: number;
81
+ }): Promise<ClaimResult | null>;
82
+ /**
83
+ * Build the claim footer for a free local scan result: posts the summary,
84
+ * returns the markdown line, or null when the claim could not be created.
85
+ */
86
+ export declare function buildClaimLine(summary: ClaimSummaryInput, opts?: {
87
+ env?: NodeJS.ProcessEnv;
88
+ fetchImpl?: typeof fetch;
89
+ timeoutMs?: number;
90
+ }): Promise<string | null>;
91
+ /** Footer for org-bound tools: deep link to the Posture Home. */
92
+ export declare function portalHomeLine(score?: number | null, env?: NodeJS.ProcessEnv): string;
93
+ //# sourceMappingURL=linkBuilder.d.ts.map
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ /**
3
+ * linkBuilder — Shield Portal deep links for tool results (U1, Phase S).
4
+ *
5
+ * Tools that produce a posture-relevant result append a single markdown line:
6
+ *
7
+ * "Posture preview: 62/100 — view full report → https://shield…/r/<token>"
8
+ *
9
+ * Two flavors:
10
+ * - Account-bound tools (bursar_get_posture, bursar_provision_app,
11
+ * bursar_compliance_report, …) link straight to the portal.
12
+ * - The FREE local scan (bursar_scan_codebase with no account) POSTs its
13
+ * ANONYMIZED summary to the public Shield API claim endpoint
14
+ * (POST /v1/claims) and links to the returned claim URL. If the API is
15
+ * unreachable/offline the scan result is returned unchanged — no link,
16
+ * no error. The golden path must never make the free tool worse.
17
+ *
18
+ * Env:
19
+ * SHIELD_PORTAL_URL portal base (default https://shield.beta.blacksandscyber.online)
20
+ * SHIELD_CLAIM_API_URL Shield API base for POST /v1/claims (default = portal host's API;
21
+ * when unset we fall back to SHIELD_API_URL, then the beta API URL)
22
+ * SHIELD_CLAIM_DISABLED set to "true" to suppress claim posting entirely
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.DEFAULT_CLAIM_API_URL = exports.DEFAULT_PORTAL_URL = void 0;
26
+ exports.portalBaseUrl = portalBaseUrl;
27
+ exports.claimApiBaseUrl = claimApiBaseUrl;
28
+ exports.buildPortalUrl = buildPortalUrl;
29
+ exports.postureLine = postureLine;
30
+ exports.previewScoreFromSeverity = previewScoreFromSeverity;
31
+ exports.postClaimSummary = postClaimSummary;
32
+ exports.buildClaimLine = buildClaimLine;
33
+ exports.portalHomeLine = portalHomeLine;
34
+ const logger_1 = require("./logger");
35
+ exports.DEFAULT_PORTAL_URL = "https://shield.beta.blacksandscyber.online";
36
+ exports.DEFAULT_CLAIM_API_URL = "https://shield-api.beta.blacksandscyber.online";
37
+ const CLAIM_TIMEOUT_MS = 4000;
38
+ /** Portal base URL, env-overridable, no trailing slash. */
39
+ function portalBaseUrl(env = process.env) {
40
+ const raw = env.SHIELD_PORTAL_URL || exports.DEFAULT_PORTAL_URL;
41
+ return raw.replace(/\/+$/, "");
42
+ }
43
+ /** Shield API base used for the public claim POST, no trailing slash. */
44
+ function claimApiBaseUrl(env = process.env) {
45
+ const raw = env.SHIELD_CLAIM_API_URL || env.SHIELD_API_URL || exports.DEFAULT_CLAIM_API_URL;
46
+ return raw.replace(/\/+$/, "");
47
+ }
48
+ /** Build a portal URL for a path ('/', '/r/<token>', …). */
49
+ function buildPortalUrl(path, env = process.env) {
50
+ const base = portalBaseUrl(env);
51
+ if (!path || path === "/")
52
+ return `${base}/`;
53
+ return `${base}${path.startsWith("/") ? "" : "/"}${path}`;
54
+ }
55
+ /**
56
+ * The one-line markdown footer. Score is clamped to 0–100; pass null/undefined
57
+ * to omit the numeric preview ("View full report → …").
58
+ */
59
+ function postureLine(score, url) {
60
+ if (score === null || score === undefined || !Number.isFinite(score)) {
61
+ return `View full report → ${url}`;
62
+ }
63
+ const clamped = Math.max(0, Math.min(100, Math.round(score)));
64
+ return `Posture preview: ${clamped}/100 — view full report → ${url}`;
65
+ }
66
+ /**
67
+ * Derive a preview score from a severity summary (free local scan has no
68
+ * server-computed posture). Deliberately simple and stable: start at 100,
69
+ * subtract per finding by severity, clamp to [5, 100] — a project with
70
+ * findings never shows 0 (demoralizing) or 100 (wrong).
71
+ */
72
+ function previewScoreFromSeverity(bySeverity) {
73
+ if (!bySeverity)
74
+ return 100;
75
+ const weights = { critical: 15, high: 8, medium: 3, low: 1, info: 0 };
76
+ let score = 100;
77
+ let findings = 0;
78
+ for (const sev of Object.keys(weights)) {
79
+ const count = Number(bySeverity[sev] ?? 0);
80
+ if (!Number.isFinite(count) || count <= 0)
81
+ continue;
82
+ findings += count;
83
+ score -= weights[sev] * count;
84
+ }
85
+ if (findings === 0)
86
+ return 100;
87
+ return Math.max(5, Math.min(99, Math.round(score)));
88
+ }
89
+ /**
90
+ * POST the anonymized summary to the public claim endpoint. Returns the claim
91
+ * URL on success, or null on ANY failure (offline, non-2xx, timeout, bad
92
+ * JSON) — callers degrade gracefully to "no link". Never throws.
93
+ */
94
+ async function postClaimSummary(summary, opts = {}) {
95
+ const env = opts.env ?? process.env;
96
+ if (String(env.SHIELD_CLAIM_DISABLED).toLowerCase() === "true")
97
+ return null;
98
+ const fetchImpl = opts.fetchImpl ?? (typeof fetch === "function" ? fetch : null);
99
+ if (!fetchImpl)
100
+ return null;
101
+ const url = `${claimApiBaseUrl(env)}/v1/claims`;
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? CLAIM_TIMEOUT_MS);
104
+ try {
105
+ const res = await fetchImpl(url, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ summary }),
109
+ signal: controller.signal,
110
+ });
111
+ if (!res.ok) {
112
+ logger_1.logger.debug?.("Claim POST returned non-2xx; skipping link", { status: res.status });
113
+ return null;
114
+ }
115
+ const body = await res.json();
116
+ if (!body?.claim_url || !body?.claim_token)
117
+ return null;
118
+ return {
119
+ claimUrl: String(body.claim_url),
120
+ claimToken: String(body.claim_token),
121
+ score: Number(body.score ?? summary.score ?? 0),
122
+ expiresAt: body.expires_at,
123
+ };
124
+ }
125
+ catch (err) {
126
+ // Offline / DNS failure / abort — the free scan must keep working.
127
+ logger_1.logger.debug?.("Claim POST failed; returning no link", { error: err?.message });
128
+ return null;
129
+ }
130
+ finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+ /**
135
+ * Build the claim footer for a free local scan result: posts the summary,
136
+ * returns the markdown line, or null when the claim could not be created.
137
+ */
138
+ async function buildClaimLine(summary, opts = {}) {
139
+ const claim = await postClaimSummary(summary, opts);
140
+ if (!claim)
141
+ return null;
142
+ return postureLine(claim.score, claim.claimUrl);
143
+ }
144
+ /** Footer for org-bound tools: deep link to the Posture Home. */
145
+ function portalHomeLine(score, env = process.env) {
146
+ return postureLine(score ?? null, buildPortalUrl("/", env));
147
+ }
148
+ //# sourceMappingURL=linkBuilder.js.map
@@ -0,0 +1,10 @@
1
+ /** Stderr-only logger — stdout is reserved for MCP JSON-RPC transport. */
2
+ export type LogLevel = "debug" | "info" | "warn" | "error";
3
+ export declare const logger: {
4
+ debug: (msg: string, data?: Record<string, unknown>) => void;
5
+ info: (msg: string, data?: Record<string, unknown>) => void;
6
+ warn: (msg: string, data?: Record<string, unknown>) => void;
7
+ error: (msg: string, data?: Record<string, unknown>) => void;
8
+ setLevel: (level: LogLevel) => void;
9
+ };
10
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /** Stderr-only logger — stdout is reserved for MCP JSON-RPC transport. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.logger = void 0;
5
+ const LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
6
+ let currentLevel = process.env.LOG_LEVEL || "info";
7
+ function shouldLog(level) {
8
+ return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel];
9
+ }
10
+ function log(level, msg, data) {
11
+ if (!shouldLog(level))
12
+ return;
13
+ const entry = {
14
+ timestamp: new Date().toISOString(),
15
+ level,
16
+ msg,
17
+ ...(data && { data }),
18
+ };
19
+ process.stderr.write(JSON.stringify(entry) + "\n");
20
+ }
21
+ exports.logger = {
22
+ debug: (msg, data) => log("debug", msg, data),
23
+ info: (msg, data) => log("info", msg, data),
24
+ warn: (msg, data) => log("warn", msg, data),
25
+ error: (msg, data) => log("error", msg, data),
26
+ setLevel: (level) => { currentLevel = level; },
27
+ };
28
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Boot-time role resolution for MCP tool registration.
3
+ *
4
+ * Why this isn't an awaited API call:
5
+ * The "right" way to know the cert's role is to call GET /v1/mcp/identity
6
+ * on Shield API. But that requires the broker handshake to complete first
7
+ * (cert+password → Authorizer → service-list), which takes 2-6 seconds in
8
+ * the steady state. Claude Desktop has a ~4-8s window to respond to the
9
+ * `initialize` MCP message, and tools must be registered synchronously
10
+ * during that window. Awaiting the broker handshake at boot pushes us
11
+ * close to that limit and makes the server fragile in slow-network
12
+ * conditions.
13
+ *
14
+ * Resolution: read the role from a fast local source (env var or local
15
+ * hint file) at boot and rely on the Shield API's requireMcpRole
16
+ * middleware (shipped in the prior commit) as the authoritative
17
+ * defense-in-depth. A stale or tampered local hint can't actually
18
+ * exfiltrate admin actions — the API still 403s.
19
+ *
20
+ * Resolution order (first match wins)
21
+ * ───────────────────────────────────
22
+ * 1. process.env.SHIELD_ROLE — explicit override, highest precedence.
23
+ * Useful for tests and for power users
24
+ * who want to show all tools in local-
25
+ * only mode for debugging.
26
+ *
27
+ * 2. ~/.blacksands/mcp-certs/.role — written by setup.js when a cert is
28
+ * issued. Captures the role chosen at
29
+ * issuance time (which could be
30
+ * 'consumer' for developer/CI agents).
31
+ *
32
+ * 3. Mode-derived default — local-only mode → 'consumer' (no
33
+ * cert means no admin authority).
34
+ * Other modes (stdio with bundle,
35
+ * http-service) → 'master' for
36
+ * backward compatibility — every cert
37
+ * issued before migration 006 defaults
38
+ * to master in the DB anyway.
39
+ *
40
+ * The local hint file is best-effort: if it's missing, malformed, or
41
+ * unreadable, fall through to the next resolution step rather than failing
42
+ * the boot. The API's requireMcpRole catches any mismatch.
43
+ */
44
+ import type { Role } from "./identity";
45
+ import type { DeploymentMode } from "../config";
46
+ export interface BootRoleResolution {
47
+ role: Role;
48
+ source: "env" | "file" | "mode-default";
49
+ /** Plain-language string for the startup log so operators can see why. */
50
+ rationale: string;
51
+ }
52
+ /**
53
+ * Resolve the MCP server's role at boot time. Synchronous (no API call).
54
+ *
55
+ * The returned role drives which tools get registered. The Shield API
56
+ * still enforces RBAC at call-time via requireMcpRole, so a wrong answer
57
+ * here at worst surfaces extra tools to the LLM — never extra capability.
58
+ */
59
+ export declare function resolveBootRole(mode: DeploymentMode): BootRoleResolution;
60
+ //# sourceMappingURL=bootRole.d.ts.map
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * Boot-time role resolution for MCP tool registration.
4
+ *
5
+ * Why this isn't an awaited API call:
6
+ * The "right" way to know the cert's role is to call GET /v1/mcp/identity
7
+ * on Shield API. But that requires the broker handshake to complete first
8
+ * (cert+password → Authorizer → service-list), which takes 2-6 seconds in
9
+ * the steady state. Claude Desktop has a ~4-8s window to respond to the
10
+ * `initialize` MCP message, and tools must be registered synchronously
11
+ * during that window. Awaiting the broker handshake at boot pushes us
12
+ * close to that limit and makes the server fragile in slow-network
13
+ * conditions.
14
+ *
15
+ * Resolution: read the role from a fast local source (env var or local
16
+ * hint file) at boot and rely on the Shield API's requireMcpRole
17
+ * middleware (shipped in the prior commit) as the authoritative
18
+ * defense-in-depth. A stale or tampered local hint can't actually
19
+ * exfiltrate admin actions — the API still 403s.
20
+ *
21
+ * Resolution order (first match wins)
22
+ * ───────────────────────────────────
23
+ * 1. process.env.SHIELD_ROLE — explicit override, highest precedence.
24
+ * Useful for tests and for power users
25
+ * who want to show all tools in local-
26
+ * only mode for debugging.
27
+ *
28
+ * 2. ~/.blacksands/mcp-certs/.role — written by setup.js when a cert is
29
+ * issued. Captures the role chosen at
30
+ * issuance time (which could be
31
+ * 'consumer' for developer/CI agents).
32
+ *
33
+ * 3. Mode-derived default — local-only mode → 'consumer' (no
34
+ * cert means no admin authority).
35
+ * Other modes (stdio with bundle,
36
+ * http-service) → 'master' for
37
+ * backward compatibility — every cert
38
+ * issued before migration 006 defaults
39
+ * to master in the DB anyway.
40
+ *
41
+ * The local hint file is best-effort: if it's missing, malformed, or
42
+ * unreadable, fall through to the next resolution step rather than failing
43
+ * the boot. The API's requireMcpRole catches any mismatch.
44
+ */
45
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
46
+ if (k2 === undefined) k2 = k;
47
+ var desc = Object.getOwnPropertyDescriptor(m, k);
48
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
49
+ desc = { enumerable: true, get: function() { return m[k]; } };
50
+ }
51
+ Object.defineProperty(o, k2, desc);
52
+ }) : (function(o, m, k, k2) {
53
+ if (k2 === undefined) k2 = k;
54
+ o[k2] = m[k];
55
+ }));
56
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
57
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
58
+ }) : function(o, v) {
59
+ o["default"] = v;
60
+ });
61
+ var __importStar = (this && this.__importStar) || (function () {
62
+ var ownKeys = function(o) {
63
+ ownKeys = Object.getOwnPropertyNames || function (o) {
64
+ var ar = [];
65
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
66
+ return ar;
67
+ };
68
+ return ownKeys(o);
69
+ };
70
+ return function (mod) {
71
+ if (mod && mod.__esModule) return mod;
72
+ var result = {};
73
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
74
+ __setModuleDefault(result, mod);
75
+ return result;
76
+ };
77
+ })();
78
+ Object.defineProperty(exports, "__esModule", { value: true });
79
+ exports.resolveBootRole = resolveBootRole;
80
+ const fs = __importStar(require("fs"));
81
+ const os = __importStar(require("os"));
82
+ const path = __importStar(require("path"));
83
+ const HOME = os.homedir();
84
+ const ROLE_HINT_FILE = path.join(HOME, ".blacksands", "mcp-certs", ".role");
85
+ function sanitizeRole(raw) {
86
+ if (!raw)
87
+ return null;
88
+ const v = raw.trim().toLowerCase();
89
+ if (v === "master" || v === "consumer")
90
+ return v;
91
+ return null;
92
+ }
93
+ function readRoleHintFile() {
94
+ try {
95
+ if (!fs.existsSync(ROLE_HINT_FILE))
96
+ return null;
97
+ const text = fs.readFileSync(ROLE_HINT_FILE, "utf8");
98
+ return sanitizeRole(text);
99
+ }
100
+ catch {
101
+ // File exists but unreadable, or read failed — fall through.
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Resolve the MCP server's role at boot time. Synchronous (no API call).
107
+ *
108
+ * The returned role drives which tools get registered. The Shield API
109
+ * still enforces RBAC at call-time via requireMcpRole, so a wrong answer
110
+ * here at worst surfaces extra tools to the LLM — never extra capability.
111
+ */
112
+ function resolveBootRole(mode) {
113
+ // 1. Explicit env override.
114
+ const envRole = sanitizeRole(process.env.SHIELD_ROLE);
115
+ if (envRole) {
116
+ return {
117
+ role: envRole,
118
+ source: "env",
119
+ rationale: `SHIELD_ROLE=${envRole} in environment`,
120
+ };
121
+ }
122
+ // 2. Local hint file written by setup.js at cert-issuance time.
123
+ const fileRole = readRoleHintFile();
124
+ if (fileRole) {
125
+ return {
126
+ role: fileRole,
127
+ source: "file",
128
+ rationale: `${ROLE_HINT_FILE} reads "${fileRole}"`,
129
+ };
130
+ }
131
+ // 3. Mode-derived default.
132
+ if (mode === "local-only") {
133
+ return {
134
+ role: "consumer",
135
+ source: "mode-default",
136
+ rationale: "local-only mode has no cert; consumer is the safer default for an unauthenticated agent",
137
+ };
138
+ }
139
+ return {
140
+ role: "master",
141
+ source: "mode-default",
142
+ rationale: `${mode} mode with no SHIELD_ROLE / role-hint file; defaulting to master for backward compatibility (existing certs default to master via migration 006)`,
143
+ };
144
+ }
145
+ //# sourceMappingURL=bootRole.js.map