@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.
- package/README.md +230 -0
- package/build/config.d.ts +45 -0
- package/build/config.js +177 -0
- package/build/http-transport.d.ts +16 -0
- package/build/http-transport.js +191 -0
- package/build/index.d.ts +16 -0
- package/build/index.js +31 -0
- package/build/server.d.ts +41 -0
- package/build/server.js +902 -0
- package/build/shared/errors.d.ts +50 -0
- package/build/shared/errors.js +69 -0
- package/build/shared/linkBuilder.d.ts +93 -0
- package/build/shared/linkBuilder.js +148 -0
- package/build/shared/logger.d.ts +10 -0
- package/build/shared/logger.js +28 -0
- package/build/shield/bootRole.d.ts +60 -0
- package/build/shield/bootRole.js +145 -0
- package/build/shield/client.d.ts +265 -0
- package/build/shield/client.js +656 -0
- package/build/shield/deploy/index.d.ts +69 -0
- package/build/shield/deploy/index.js +569 -0
- package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
- package/build/shield/discovery/dataStoreDetector.js +125 -0
- package/build/shield/discovery/dockerScanner.d.ts +34 -0
- package/build/shield/discovery/dockerScanner.js +543 -0
- package/build/shield/discovery/endpointScanner.d.ts +3 -0
- package/build/shield/discovery/endpointScanner.js +306 -0
- package/build/shield/discovery/environmentScanner.d.ts +86 -0
- package/build/shield/discovery/environmentScanner.js +545 -0
- package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
- package/build/shield/discovery/externalServiceDetector.js +98 -0
- package/build/shield/discovery/frameworkDetector.d.ts +3 -0
- package/build/shield/discovery/frameworkDetector.js +114 -0
- package/build/shield/discovery/manifestGenerator.d.ts +12 -0
- package/build/shield/discovery/manifestGenerator.js +124 -0
- package/build/shield/discovery/piiDetector.d.ts +5 -0
- package/build/shield/discovery/piiDetector.js +203 -0
- package/build/shield/discovery/severity.d.ts +47 -0
- package/build/shield/discovery/severity.js +138 -0
- package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
- package/build/shield/discovery/topologyNormalizer.js +416 -0
- package/build/shield/identity.d.ts +53 -0
- package/build/shield/identity.js +70 -0
- package/build/shield/install/configMerge.d.ts +91 -0
- package/build/shield/install/configMerge.js +324 -0
- package/build/shield/install/keystore.d.ts +25 -0
- package/build/shield/install/keystore.js +156 -0
- package/build/shield/install/orchestrator.d.ts +33 -0
- package/build/shield/install/orchestrator.js +404 -0
- package/build/shield/install/transports/awsSsm.d.ts +43 -0
- package/build/shield/install/transports/awsSsm.js +378 -0
- package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
- package/build/shield/install/transports/bootstrapToken.js +117 -0
- package/build/shield/install/transports/ssh.d.ts +50 -0
- package/build/shield/install/transports/ssh.js +569 -0
- package/build/shield/install/types.d.ts +139 -0
- package/build/shield/install/types.js +10 -0
- package/build/shield/protocol-walkthrough.d.ts +65 -0
- package/build/shield/protocol-walkthrough.js +392 -0
- package/build/shield/provision/appProvisioner.d.ts +15 -0
- package/build/shield/provision/appProvisioner.js +25 -0
- package/build/shield/types.d.ts +261 -0
- package/build/shield/types.js +4 -0
- package/build/shield/verify/postureReporter.d.ts +4 -0
- package/build/shield/verify/postureReporter.js +31 -0
- package/dxt/blacksands-ca.crt +67 -0
- package/dxt/scripts/setup.js +520 -0
- package/package.json +76 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP config-file merge for `bursar_install_agent_remotely`.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities (from SHIELD-INSTALL-AGENT-REMOTELY-REQUIREMENTS.md §FR-5):
|
|
5
|
+
* - Resolve the default config path for an agentType on a given platform.
|
|
6
|
+
* - Merge a "blacksands-shield" MCP server stanza into an existing JSON
|
|
7
|
+
* config, never clobbering unrelated keys.
|
|
8
|
+
* - Abort when the target file parses as something we don't recognize.
|
|
9
|
+
* - Never silently set SHIELD_API_KEY; explicitly remove it if present.
|
|
10
|
+
* - Produce an atomic write plan: compute the new content and a .bak of
|
|
11
|
+
* the old content so the orchestrator can roll back.
|
|
12
|
+
*
|
|
13
|
+
* This module is PURE: it does not touch the filesystem. It takes the
|
|
14
|
+
* current file content (string | null) as input and returns the new
|
|
15
|
+
* content, the backup, and a structured diff. The orchestrator handles
|
|
16
|
+
* the actual reads/writes — keeps this unit-testable without mocks.
|
|
17
|
+
*/
|
|
18
|
+
export type AgentType = "claude-desktop" | "mcp-server" | "openclaw" | "custom";
|
|
19
|
+
export type Platform = "darwin" | "win32" | "linux";
|
|
20
|
+
export interface ConfigEnv {
|
|
21
|
+
SHIELD_CONNECTION_MODE: "broker";
|
|
22
|
+
SHIELD_AUTHORIZER_URL: string;
|
|
23
|
+
SHIELD_SERVICE_ID: string;
|
|
24
|
+
SHIELD_AUTH_PASSWORD: string;
|
|
25
|
+
SHIELD_CLIENT_CERT: string;
|
|
26
|
+
SHIELD_CLIENT_KEY: string;
|
|
27
|
+
SHIELD_CA_CERT?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MergeInput {
|
|
30
|
+
agentType: AgentType;
|
|
31
|
+
/** Current file content; null if the file does not exist. */
|
|
32
|
+
current: string | null;
|
|
33
|
+
/** Name to use as the JSON key under mcpServers. */
|
|
34
|
+
serverKey: string;
|
|
35
|
+
/** Env vars to inject into the server stanza. */
|
|
36
|
+
env: ConfigEnv;
|
|
37
|
+
/** Absolute path to the MCP server binary on the target (or command invocation). */
|
|
38
|
+
command: string;
|
|
39
|
+
args?: string[];
|
|
40
|
+
}
|
|
41
|
+
export interface MergeResult {
|
|
42
|
+
/** The new file content that should be written atomically. */
|
|
43
|
+
nextContent: string;
|
|
44
|
+
/** The previous content (null if file didn't exist) — used for .bak. */
|
|
45
|
+
previousContent: string | null;
|
|
46
|
+
/** Per-key summary of what changed, for human-readable dry-run output. */
|
|
47
|
+
diff: {
|
|
48
|
+
action: "create-file" | "insert-server" | "overwrite-server" | "noop";
|
|
49
|
+
serverKey: string;
|
|
50
|
+
removedKeys: string[];
|
|
51
|
+
notes: string[];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the default config-file path for an (agentType, platform) pair.
|
|
56
|
+
* Uses a provided home directory so this function is pure and testable.
|
|
57
|
+
* The `custom` agentType has no default — callers MUST pass an explicit path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function defaultConfigPath(agentType: AgentType, platform: Platform, homeDir: string, appDataDir?: string): string | null;
|
|
60
|
+
/** Convenience: resolve config path from runtime context with current-process fallbacks. */
|
|
61
|
+
export declare function resolveConfigPath(agentType: AgentType, explicit?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Merge the blacksands-shield server stanza into a JSON config file.
|
|
64
|
+
*
|
|
65
|
+
* Merge semantics:
|
|
66
|
+
* - If file doesn't exist → create `{ "mcpServers": { [serverKey]: <stanza> } }`.
|
|
67
|
+
* - If file exists and parses as JSON object → preserve every existing key
|
|
68
|
+
* except `mcpServers[serverKey]`, which we replace. If the existing stanza
|
|
69
|
+
* had a SHIELD_API_KEY env var, we drop it (reported in diff.removedKeys).
|
|
70
|
+
* - If file exists but isn't parseable JSON → abort with InstallError(phase: apply-config).
|
|
71
|
+
* - If `mcpServers` exists but isn't an object → abort; same reasoning.
|
|
72
|
+
*/
|
|
73
|
+
export declare function mergeJsonConfig(input: MergeInput, filePath: string): MergeResult;
|
|
74
|
+
/**
|
|
75
|
+
* YAML merge for OpenClaw's config.yaml. Same invariants as JSON:
|
|
76
|
+
* - preserve every existing key
|
|
77
|
+
* - strip legacy SHIELD_API_KEY if present
|
|
78
|
+
* - abort on unparseable YAML or non-object root
|
|
79
|
+
*
|
|
80
|
+
* OpenClaw's schema expects an `mcp_servers:` map (snake_case) at the root.
|
|
81
|
+
* We write/replace the "blacksands-shield" entry inside it; all sibling
|
|
82
|
+
* entries are left untouched.
|
|
83
|
+
*/
|
|
84
|
+
export declare function mergeYamlConfig(input: MergeInput, filePath: string): MergeResult;
|
|
85
|
+
/**
|
|
86
|
+
* Merge dispatcher: picks the right format based on agentType.
|
|
87
|
+
* - openclaw → YAML (mcp_servers: …)
|
|
88
|
+
* - everything else → JSON (mcpServers: …)
|
|
89
|
+
*/
|
|
90
|
+
export declare function mergeConfig(input: MergeInput, filePath: string): MergeResult;
|
|
91
|
+
//# sourceMappingURL=configMerge.d.ts.map
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MCP config-file merge for `bursar_install_agent_remotely`.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities (from SHIELD-INSTALL-AGENT-REMOTELY-REQUIREMENTS.md §FR-5):
|
|
6
|
+
* - Resolve the default config path for an agentType on a given platform.
|
|
7
|
+
* - Merge a "blacksands-shield" MCP server stanza into an existing JSON
|
|
8
|
+
* config, never clobbering unrelated keys.
|
|
9
|
+
* - Abort when the target file parses as something we don't recognize.
|
|
10
|
+
* - Never silently set SHIELD_API_KEY; explicitly remove it if present.
|
|
11
|
+
* - Produce an atomic write plan: compute the new content and a .bak of
|
|
12
|
+
* the old content so the orchestrator can roll back.
|
|
13
|
+
*
|
|
14
|
+
* This module is PURE: it does not touch the filesystem. It takes the
|
|
15
|
+
* current file content (string | null) as input and returns the new
|
|
16
|
+
* content, the backup, and a structured diff. The orchestrator handles
|
|
17
|
+
* the actual reads/writes — keeps this unit-testable without mocks.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.defaultConfigPath = defaultConfigPath;
|
|
54
|
+
exports.resolveConfigPath = resolveConfigPath;
|
|
55
|
+
exports.mergeJsonConfig = mergeJsonConfig;
|
|
56
|
+
exports.mergeYamlConfig = mergeYamlConfig;
|
|
57
|
+
exports.mergeConfig = mergeConfig;
|
|
58
|
+
const path = __importStar(require("path"));
|
|
59
|
+
const os = __importStar(require("os"));
|
|
60
|
+
const yaml = __importStar(require("js-yaml"));
|
|
61
|
+
const errors_1 = require("../../shared/errors");
|
|
62
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Default config paths per agentType + platform
|
|
64
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* Resolves the default config-file path for an (agentType, platform) pair.
|
|
67
|
+
* Uses a provided home directory so this function is pure and testable.
|
|
68
|
+
* The `custom` agentType has no default — callers MUST pass an explicit path.
|
|
69
|
+
*/
|
|
70
|
+
function defaultConfigPath(agentType, platform, homeDir, appDataDir) {
|
|
71
|
+
if (agentType === "claude-desktop") {
|
|
72
|
+
if (platform === "darwin") {
|
|
73
|
+
return path.join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
74
|
+
}
|
|
75
|
+
if (platform === "win32") {
|
|
76
|
+
const appData = appDataDir || path.join(homeDir, "AppData", "Roaming");
|
|
77
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
78
|
+
}
|
|
79
|
+
// Linux — Anthropic doesn't officially ship Claude Desktop for Linux, but
|
|
80
|
+
// the documented XDG path is the one third-party builds use.
|
|
81
|
+
return path.join(homeDir, ".config", "Claude", "claude_desktop_config.json");
|
|
82
|
+
}
|
|
83
|
+
if (agentType === "mcp-server") {
|
|
84
|
+
return path.join(homeDir, ".config", "blacksands", ".mcp.json");
|
|
85
|
+
}
|
|
86
|
+
if (agentType === "openclaw") {
|
|
87
|
+
return path.join(homeDir, ".openclaw", "config.yaml");
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** Convenience: resolve config path from runtime context with current-process fallbacks. */
|
|
92
|
+
function resolveConfigPath(agentType, explicit) {
|
|
93
|
+
if (explicit)
|
|
94
|
+
return explicit;
|
|
95
|
+
const platform = process.platform;
|
|
96
|
+
const home = os.homedir();
|
|
97
|
+
const appData = process.env.APPDATA;
|
|
98
|
+
const resolved = defaultConfigPath(agentType, platform, home, appData);
|
|
99
|
+
if (!resolved) {
|
|
100
|
+
throw new errors_1.InstallError(`agentType "${agentType}" has no default config path; pass configPath explicitly`, { phase: "apply-config", next_steps: ['Pass a `configPath` value in the tool input.'] }, 400);
|
|
101
|
+
}
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
105
|
+
// JSON config merge (claude-desktop, mcp-server)
|
|
106
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Build the MCP server stanza that gets inserted under `mcpServers[serverKey]`.
|
|
109
|
+
* Returns a plain object; the caller serializes it as part of the full file.
|
|
110
|
+
*
|
|
111
|
+
* The env block explicitly OMITS SHIELD_API_KEY. If the existing stanza had
|
|
112
|
+
* one, we strip it (reported back in diff.removedKeys).
|
|
113
|
+
*/
|
|
114
|
+
function buildServerStanza(command, args, env) {
|
|
115
|
+
const envOut = {
|
|
116
|
+
SHIELD_CONNECTION_MODE: env.SHIELD_CONNECTION_MODE,
|
|
117
|
+
SHIELD_AUTHORIZER_URL: env.SHIELD_AUTHORIZER_URL,
|
|
118
|
+
SHIELD_SERVICE_ID: env.SHIELD_SERVICE_ID,
|
|
119
|
+
SHIELD_AUTH_PASSWORD: env.SHIELD_AUTH_PASSWORD,
|
|
120
|
+
SHIELD_CLIENT_CERT: env.SHIELD_CLIENT_CERT,
|
|
121
|
+
SHIELD_CLIENT_KEY: env.SHIELD_CLIENT_KEY,
|
|
122
|
+
};
|
|
123
|
+
if (env.SHIELD_CA_CERT)
|
|
124
|
+
envOut.SHIELD_CA_CERT = env.SHIELD_CA_CERT;
|
|
125
|
+
return {
|
|
126
|
+
command,
|
|
127
|
+
args: args || [],
|
|
128
|
+
env: envOut,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Parse JSON with a precise error — we want to tell the operator which file
|
|
133
|
+
* failed to parse, not just hand them "Unexpected token {".
|
|
134
|
+
*/
|
|
135
|
+
function safeParseJson(raw, filePath) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
139
|
+
throw new errors_1.InstallError(`Config file at ${filePath} is not a JSON object — refusing to modify`, {
|
|
140
|
+
phase: "apply-config",
|
|
141
|
+
next_steps: [
|
|
142
|
+
`Inspect ${filePath}. If it was intentionally customized, back it up and delete it, then retry.`,
|
|
143
|
+
],
|
|
144
|
+
}, 409);
|
|
145
|
+
}
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof errors_1.InstallError)
|
|
150
|
+
throw err;
|
|
151
|
+
throw new errors_1.InstallError(`Config file at ${filePath} is not valid JSON: ${err.message}`, {
|
|
152
|
+
phase: "apply-config",
|
|
153
|
+
next_steps: [
|
|
154
|
+
`Fix or remove ${filePath} before retrying. The tool refuses to overwrite unparseable files to avoid data loss.`,
|
|
155
|
+
],
|
|
156
|
+
}, 409);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Merge the blacksands-shield server stanza into a JSON config file.
|
|
161
|
+
*
|
|
162
|
+
* Merge semantics:
|
|
163
|
+
* - If file doesn't exist → create `{ "mcpServers": { [serverKey]: <stanza> } }`.
|
|
164
|
+
* - If file exists and parses as JSON object → preserve every existing key
|
|
165
|
+
* except `mcpServers[serverKey]`, which we replace. If the existing stanza
|
|
166
|
+
* had a SHIELD_API_KEY env var, we drop it (reported in diff.removedKeys).
|
|
167
|
+
* - If file exists but isn't parseable JSON → abort with InstallError(phase: apply-config).
|
|
168
|
+
* - If `mcpServers` exists but isn't an object → abort; same reasoning.
|
|
169
|
+
*/
|
|
170
|
+
function mergeJsonConfig(input, filePath) {
|
|
171
|
+
const notes = [];
|
|
172
|
+
const removedKeys = [];
|
|
173
|
+
const stanza = buildServerStanza(input.command, input.args, input.env);
|
|
174
|
+
if (input.current === null || input.current.trim() === "") {
|
|
175
|
+
const nextObj = { mcpServers: { [input.serverKey]: stanza } };
|
|
176
|
+
notes.push(`Created new config file at ${filePath}.`);
|
|
177
|
+
return {
|
|
178
|
+
nextContent: JSON.stringify(nextObj, null, 2) + "\n",
|
|
179
|
+
previousContent: null,
|
|
180
|
+
diff: { action: "create-file", serverKey: input.serverKey, removedKeys, notes },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const parsed = safeParseJson(input.current, filePath);
|
|
184
|
+
let action = "insert-server";
|
|
185
|
+
// mcpServers must be an object (or absent). Arrays/primitives → abort.
|
|
186
|
+
let mcpServers = parsed.mcpServers;
|
|
187
|
+
if (mcpServers === undefined) {
|
|
188
|
+
mcpServers = {};
|
|
189
|
+
}
|
|
190
|
+
else if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
|
|
191
|
+
throw new errors_1.InstallError(`Config file at ${filePath} has a non-object "mcpServers" key — refusing to modify`, {
|
|
192
|
+
phase: "apply-config",
|
|
193
|
+
next_steps: [`Fix the "mcpServers" key in ${filePath} to be a JSON object, then retry.`],
|
|
194
|
+
}, 409);
|
|
195
|
+
}
|
|
196
|
+
const mcpServersObj = mcpServers;
|
|
197
|
+
const existing = mcpServersObj[input.serverKey];
|
|
198
|
+
if (existing !== undefined) {
|
|
199
|
+
action = "overwrite-server";
|
|
200
|
+
notes.push(`Overwriting existing "${input.serverKey}" server stanza.`);
|
|
201
|
+
// Detect stripped SHIELD_API_KEY for reporting
|
|
202
|
+
if (existing !== null &&
|
|
203
|
+
typeof existing === "object" &&
|
|
204
|
+
!Array.isArray(existing) &&
|
|
205
|
+
"env" in existing &&
|
|
206
|
+
existing.env !== null &&
|
|
207
|
+
typeof existing.env === "object") {
|
|
208
|
+
const existingEnv = existing.env;
|
|
209
|
+
if ("SHIELD_API_KEY" in existingEnv) {
|
|
210
|
+
removedKeys.push(`mcpServers.${input.serverKey}.env.SHIELD_API_KEY`);
|
|
211
|
+
notes.push("Removed legacy SHIELD_API_KEY from existing stanza — broker mode requires mTLS + auth password only.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const nextObj = {
|
|
216
|
+
...parsed,
|
|
217
|
+
mcpServers: {
|
|
218
|
+
...mcpServersObj,
|
|
219
|
+
[input.serverKey]: stanza,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
// Preserve insertion order as closely as possible: if mcpServers wasn't
|
|
223
|
+
// originally in the parsed object, it will show up at the end, which
|
|
224
|
+
// matches what `JSON.stringify` does by default on spread.
|
|
225
|
+
return {
|
|
226
|
+
nextContent: JSON.stringify(nextObj, null, 2) + "\n",
|
|
227
|
+
previousContent: input.current,
|
|
228
|
+
diff: { action, serverKey: input.serverKey, removedKeys, notes },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
232
|
+
// YAML config merge (openclaw)
|
|
233
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* YAML merge for OpenClaw's config.yaml. Same invariants as JSON:
|
|
236
|
+
* - preserve every existing key
|
|
237
|
+
* - strip legacy SHIELD_API_KEY if present
|
|
238
|
+
* - abort on unparseable YAML or non-object root
|
|
239
|
+
*
|
|
240
|
+
* OpenClaw's schema expects an `mcp_servers:` map (snake_case) at the root.
|
|
241
|
+
* We write/replace the "blacksands-shield" entry inside it; all sibling
|
|
242
|
+
* entries are left untouched.
|
|
243
|
+
*/
|
|
244
|
+
function mergeYamlConfig(input, filePath) {
|
|
245
|
+
const notes = [];
|
|
246
|
+
const removedKeys = [];
|
|
247
|
+
const stanza = buildServerStanza(input.command, input.args, input.env);
|
|
248
|
+
if (input.current === null || input.current.trim() === "") {
|
|
249
|
+
const nextObj = { mcp_servers: { [input.serverKey]: stanza } };
|
|
250
|
+
notes.push(`Created new YAML config at ${filePath}.`);
|
|
251
|
+
return {
|
|
252
|
+
nextContent: yaml.dump(nextObj, { lineWidth: 120, noRefs: true }),
|
|
253
|
+
previousContent: null,
|
|
254
|
+
diff: { action: "create-file", serverKey: input.serverKey, removedKeys, notes },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = yaml.load(input.current);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
throw new errors_1.InstallError(`Config file at ${filePath} is not valid YAML: ${err.message}`, {
|
|
263
|
+
phase: "apply-config",
|
|
264
|
+
next_steps: [`Fix or remove ${filePath}. The tool refuses to overwrite unparseable files.`],
|
|
265
|
+
}, 409);
|
|
266
|
+
}
|
|
267
|
+
if (parsed === null || parsed === undefined) {
|
|
268
|
+
parsed = {};
|
|
269
|
+
}
|
|
270
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
271
|
+
throw new errors_1.InstallError(`Config file at ${filePath} root is not a YAML mapping — refusing to modify`, { phase: "apply-config" }, 409);
|
|
272
|
+
}
|
|
273
|
+
const root = parsed;
|
|
274
|
+
let action = "insert-server";
|
|
275
|
+
let mcpServers = root.mcp_servers;
|
|
276
|
+
if (mcpServers === undefined) {
|
|
277
|
+
mcpServers = {};
|
|
278
|
+
}
|
|
279
|
+
else if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
|
|
280
|
+
throw new errors_1.InstallError(`Config file at ${filePath} has a non-mapping "mcp_servers" key — refusing to modify`, { phase: "apply-config" }, 409);
|
|
281
|
+
}
|
|
282
|
+
const mcpServersObj = mcpServers;
|
|
283
|
+
const existing = mcpServersObj[input.serverKey];
|
|
284
|
+
if (existing !== undefined) {
|
|
285
|
+
action = "overwrite-server";
|
|
286
|
+
notes.push(`Overwriting existing "${input.serverKey}" entry.`);
|
|
287
|
+
if (existing !== null &&
|
|
288
|
+
typeof existing === "object" &&
|
|
289
|
+
!Array.isArray(existing) &&
|
|
290
|
+
"env" in existing &&
|
|
291
|
+
typeof existing.env === "object" &&
|
|
292
|
+
existing.env !== null) {
|
|
293
|
+
const existingEnv = existing.env;
|
|
294
|
+
if ("SHIELD_API_KEY" in existingEnv) {
|
|
295
|
+
removedKeys.push(`mcp_servers.${input.serverKey}.env.SHIELD_API_KEY`);
|
|
296
|
+
notes.push("Removed legacy SHIELD_API_KEY from existing entry — broker mode uses mTLS + auth password only.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const nextObj = {
|
|
301
|
+
...root,
|
|
302
|
+
mcp_servers: {
|
|
303
|
+
...mcpServersObj,
|
|
304
|
+
[input.serverKey]: stanza,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
return {
|
|
308
|
+
nextContent: yaml.dump(nextObj, { lineWidth: 120, noRefs: true }),
|
|
309
|
+
previousContent: input.current,
|
|
310
|
+
diff: { action, serverKey: input.serverKey, removedKeys, notes },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Merge dispatcher: picks the right format based on agentType.
|
|
315
|
+
* - openclaw → YAML (mcp_servers: …)
|
|
316
|
+
* - everything else → JSON (mcpServers: …)
|
|
317
|
+
*/
|
|
318
|
+
function mergeConfig(input, filePath) {
|
|
319
|
+
if (input.agentType === "openclaw") {
|
|
320
|
+
return mergeYamlConfig(input, filePath);
|
|
321
|
+
}
|
|
322
|
+
return mergeJsonConfig(input, filePath);
|
|
323
|
+
}
|
|
324
|
+
//# sourceMappingURL=configMerge.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type KeystoreBackend = "keychain" | "libsecret" | "wincred" | "none";
|
|
2
|
+
export type KeystoreMode = "off" | "auto" | "on";
|
|
3
|
+
/** Service/label namespace under which secrets are filed in the OS store. */
|
|
4
|
+
export declare const KEYSTORE_SERVICE = "blacksands-shield-mcp";
|
|
5
|
+
/** The secret kinds we manage. */
|
|
6
|
+
export type SecretKind = "key" | "password";
|
|
7
|
+
/** Account name for a secret: "<clientName>.<kind>". */
|
|
8
|
+
export declare function secretAccount(clientName: string, kind: SecretKind): string;
|
|
9
|
+
/** Parse the BS_MCP_KEYSTORE gate. Default "off" (disk-only, no behavior change). */
|
|
10
|
+
export declare function keystoreMode(env?: NodeJS.ProcessEnv): KeystoreMode;
|
|
11
|
+
/** Which backend this platform can use (ignores the mode gate). */
|
|
12
|
+
export declare function detectBackend(platform?: NodeJS.Platform): KeystoreBackend;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the keystore should be used right now: the mode is not "off" AND a
|
|
15
|
+
* backend is available. With mode "on" and no backend, we still report false
|
|
16
|
+
* (callers fall back to disk) but should surface a warning — see storeSecret.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isKeystoreAvailable(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
19
|
+
/** Store a secret. Returns true on success, false on any failure (caller falls back to disk). */
|
|
20
|
+
export declare function storeSecret(account: string, secret: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
21
|
+
/** Retrieve a secret. Returns the value, or null if absent / unavailable / on error. */
|
|
22
|
+
export declare function retrieveSecret(account: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | null;
|
|
23
|
+
/** Delete a secret. Best-effort; returns true if the backend reported success. */
|
|
24
|
+
export declare function removeSecret(account: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
25
|
+
//# sourceMappingURL=keystore.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KEYSTORE_SERVICE = void 0;
|
|
4
|
+
exports.secretAccount = secretAccount;
|
|
5
|
+
exports.keystoreMode = keystoreMode;
|
|
6
|
+
exports.detectBackend = detectBackend;
|
|
7
|
+
exports.isKeystoreAvailable = isKeystoreAvailable;
|
|
8
|
+
exports.storeSecret = storeSecret;
|
|
9
|
+
exports.retrieveSecret = retrieveSecret;
|
|
10
|
+
exports.removeSecret = removeSecret;
|
|
11
|
+
/** OS keystore storage for MCP installation secrets (F4.11, Gap #9 PR-G9-5).
|
|
12
|
+
*
|
|
13
|
+
* The installation-bound MCP private key and broker auth-password are the two
|
|
14
|
+
* sensitive items in ~/.blacksands/mcp-certs/. This module stores them in the
|
|
15
|
+
* platform secret store instead of (or in addition to) plain disk files:
|
|
16
|
+
* - macOS → Keychain (`security` generic password)
|
|
17
|
+
* - Linux → libsecret (`secret-tool`)
|
|
18
|
+
* - Windows → not yet wired (falls back to disk; see GAP9 design doc)
|
|
19
|
+
*
|
|
20
|
+
* Design constraints:
|
|
21
|
+
* - Every operation is best-effort and NEVER throws. A keystore miss or a
|
|
22
|
+
* command failure returns false/null so the caller transparently falls
|
|
23
|
+
* back to the existing disk path — credential handling must never make an
|
|
24
|
+
* install worse than it is today.
|
|
25
|
+
* - Gated by BS_MCP_KEYSTORE (off | auto | on); default "off" preserves the
|
|
26
|
+
* current disk-only behavior byte-for-byte until a deployment opts in.
|
|
27
|
+
* - The cert and CA are public and stay on disk; only the key + password are
|
|
28
|
+
* candidates for the keystore.
|
|
29
|
+
*
|
|
30
|
+
* All shelling-out uses execFileSync (argv array, no shell) so a secret can
|
|
31
|
+
* never be interpreted by a shell.
|
|
32
|
+
*/
|
|
33
|
+
const child_process_1 = require("child_process");
|
|
34
|
+
/** Service/label namespace under which secrets are filed in the OS store. */
|
|
35
|
+
exports.KEYSTORE_SERVICE = "blacksands-shield-mcp";
|
|
36
|
+
/** Account name for a secret: "<clientName>.<kind>". */
|
|
37
|
+
function secretAccount(clientName, kind) {
|
|
38
|
+
return `${clientName}.${kind}`;
|
|
39
|
+
}
|
|
40
|
+
/** Parse the BS_MCP_KEYSTORE gate. Default "off" (disk-only, no behavior change). */
|
|
41
|
+
function keystoreMode(env = process.env) {
|
|
42
|
+
const v = (env.BS_MCP_KEYSTORE || "").trim().toLowerCase();
|
|
43
|
+
if (v === "on" || v === "1" || v === "true" || v === "require")
|
|
44
|
+
return "on";
|
|
45
|
+
if (v === "auto")
|
|
46
|
+
return "auto";
|
|
47
|
+
return "off";
|
|
48
|
+
}
|
|
49
|
+
/** True if a command resolves on PATH (no output, no throw on success). */
|
|
50
|
+
function commandExists(cmd, platform = process.platform) {
|
|
51
|
+
try {
|
|
52
|
+
(0, child_process_1.execFileSync)(platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Which backend this platform can use (ignores the mode gate). */
|
|
60
|
+
function detectBackend(platform = process.platform) {
|
|
61
|
+
if (platform === "darwin")
|
|
62
|
+
return commandExists("security", platform) ? "keychain" : "none";
|
|
63
|
+
if (platform === "linux")
|
|
64
|
+
return commandExists("secret-tool", platform) ? "libsecret" : "none";
|
|
65
|
+
// win32: Credential Manager wiring is a follow-up (PR-G9-5b). Fall back to disk.
|
|
66
|
+
return "none";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether the keystore should be used right now: the mode is not "off" AND a
|
|
70
|
+
* backend is available. With mode "on" and no backend, we still report false
|
|
71
|
+
* (callers fall back to disk) but should surface a warning — see storeSecret.
|
|
72
|
+
*/
|
|
73
|
+
function isKeystoreAvailable(env = process.env, platform = process.platform) {
|
|
74
|
+
if (keystoreMode(env) === "off")
|
|
75
|
+
return false;
|
|
76
|
+
return detectBackend(platform) !== "none";
|
|
77
|
+
}
|
|
78
|
+
/** Store a secret. Returns true on success, false on any failure (caller falls back to disk). */
|
|
79
|
+
function storeSecret(account, secret, env = process.env, platform = process.platform) {
|
|
80
|
+
if (keystoreMode(env) === "off")
|
|
81
|
+
return false;
|
|
82
|
+
const backend = detectBackend(platform);
|
|
83
|
+
try {
|
|
84
|
+
if (backend === "keychain") {
|
|
85
|
+
// -U updates an existing item without prompting; -w takes the secret value.
|
|
86
|
+
(0, child_process_1.execFileSync)("security", [
|
|
87
|
+
"add-generic-password", "-U",
|
|
88
|
+
"-a", account, "-s", exports.KEYSTORE_SERVICE, "-w", secret,
|
|
89
|
+
], { stdio: "ignore" });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (backend === "libsecret") {
|
|
93
|
+
// secret-tool reads the secret from stdin (not argv) — preferred.
|
|
94
|
+
(0, child_process_1.execFileSync)("secret-tool", [
|
|
95
|
+
"store", "--label", `${exports.KEYSTORE_SERVICE}:${account}`,
|
|
96
|
+
"service", exports.KEYSTORE_SERVICE, "account", account,
|
|
97
|
+
], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Retrieve a secret. Returns the value, or null if absent / unavailable / on error. */
|
|
107
|
+
function retrieveSecret(account, env = process.env, platform = process.platform) {
|
|
108
|
+
if (keystoreMode(env) === "off")
|
|
109
|
+
return null;
|
|
110
|
+
const backend = detectBackend(platform);
|
|
111
|
+
try {
|
|
112
|
+
if (backend === "keychain") {
|
|
113
|
+
const out = (0, child_process_1.execFileSync)("security", [
|
|
114
|
+
"find-generic-password", "-a", account, "-s", exports.KEYSTORE_SERVICE, "-w",
|
|
115
|
+
], { encoding: "utf8" });
|
|
116
|
+
// `security -w` emits the password followed by a trailing newline.
|
|
117
|
+
return out.length > 0 ? out.replace(/\r?\n$/, "") : null;
|
|
118
|
+
}
|
|
119
|
+
if (backend === "libsecret") {
|
|
120
|
+
const out = (0, child_process_1.execFileSync)("secret-tool", [
|
|
121
|
+
"lookup", "service", exports.KEYSTORE_SERVICE, "account", account,
|
|
122
|
+
], { encoding: "utf8" });
|
|
123
|
+
return out.length > 0 ? out.replace(/\r?\n$/, "") : null;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Non-zero exit (item not found) or command missing → caller uses disk.
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Delete a secret. Best-effort; returns true if the backend reported success. */
|
|
133
|
+
function removeSecret(account, env = process.env, platform = process.platform) {
|
|
134
|
+
if (keystoreMode(env) === "off")
|
|
135
|
+
return false;
|
|
136
|
+
const backend = detectBackend(platform);
|
|
137
|
+
try {
|
|
138
|
+
if (backend === "keychain") {
|
|
139
|
+
(0, child_process_1.execFileSync)("security", [
|
|
140
|
+
"delete-generic-password", "-a", account, "-s", exports.KEYSTORE_SERVICE,
|
|
141
|
+
], { stdio: "ignore" });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (backend === "libsecret") {
|
|
145
|
+
(0, child_process_1.execFileSync)("secret-tool", [
|
|
146
|
+
"clear", "service", exports.KEYSTORE_SERVICE, "account", account,
|
|
147
|
+
], { stdio: "ignore" });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=keystore.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator for bursar_install_agent_remotely.
|
|
3
|
+
*
|
|
4
|
+
* Coordinates: precheck → transport.deliver → (config merge + restart are
|
|
5
|
+
* the target's job for bootstrap-token; handled by the transport itself
|
|
6
|
+
* for SSH/SSM in later phases) → handshake verification → rollback on
|
|
7
|
+
* failure.
|
|
8
|
+
*
|
|
9
|
+
* The orchestrator is intentionally thin. Transport-specific behavior
|
|
10
|
+
* lives in the Transport classes; config-file manipulation lives in
|
|
11
|
+
* configMerge. This file is just the state machine.
|
|
12
|
+
*
|
|
13
|
+
* See SHIELD-INSTALL-AGENT-REMOTELY-REQUIREMENTS.md §FR-1 through §FR-12.
|
|
14
|
+
*/
|
|
15
|
+
import type { ShieldClient } from "../client";
|
|
16
|
+
import type { InstallInput, InstallResult } from "./types";
|
|
17
|
+
export interface RunInstallDeps {
|
|
18
|
+
shield: ShieldClient;
|
|
19
|
+
/** Authorizer URL to fall back to when input.authorizerUrl is omitted. */
|
|
20
|
+
defaultAuthorizerUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function runInstall(input: InstallInput, deps: RunInstallDeps): Promise<InstallResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Run install with full lifecycle: advisory lock → audit start →
|
|
25
|
+
* precheck → transport.deliver → handshake poll → audit finalize →
|
|
26
|
+
* lock release. On failure after precheck, the transport's rollback
|
|
27
|
+
* hook runs (SSH rolls back files + revokes the cert; bootstrap-token
|
|
28
|
+
* deletes the minted token).
|
|
29
|
+
*
|
|
30
|
+
* Exposed as the public entry point used by the tool handler in server.ts.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runInstallWithRollback(input: InstallInput, deps: RunInstallDeps): Promise<InstallResult>;
|
|
33
|
+
//# sourceMappingURL=orchestrator.d.ts.map
|