sentinelayer-cli 0.30.12 → 0.30.14

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.
@@ -0,0 +1,506 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ import { buildSentinelayerCliRegistryTemplate } from "./cli-registry.js";
7
+
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const MAX_TIMEOUT_MS = 300_000;
10
+ const DEFAULT_CLI_BIN = fileURLToPath(new URL("../../bin/sl.js", import.meta.url));
11
+ const CLI_TOOL_NAME_PREFIX = "sl.";
12
+ const BRIDGE_DISABLED_ENV = "SENTINELAYER_MCP_CLI_BRIDGE_DISABLED";
13
+ const CLI_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
14
+ const CLI_OPTION_FLAG_RE = /^--?[a-zA-Z0-9][a-zA-Z0-9-]*$/;
15
+ const BLOCKED_COMMAND_PATHS = new Set(["mcp.server.run"]);
16
+ const SENSITIVE_COMMAND_PATHS = new Set([
17
+ "auth.login",
18
+ "auth.logout",
19
+ "auth.revoke",
20
+ "config.edit",
21
+ "config.get",
22
+ "config.list",
23
+ "config.set",
24
+ "policy.use",
25
+ "session.admin-kill",
26
+ "session.admin-kill-all",
27
+ "session.cleanup",
28
+ "session.daemon",
29
+ "session.kill",
30
+ "session.listen",
31
+ "session.provision-emails",
32
+ ]);
33
+ const REDACTION_MARKER = "[REDACTED]";
34
+
35
+ function normalizeString(value) {
36
+ return String(value == null ? "" : value).trim();
37
+ }
38
+
39
+ function normalizePositiveInteger(value, fallbackValue) {
40
+ if (value === undefined || value === null || String(value).trim() === "") {
41
+ return fallbackValue;
42
+ }
43
+ const normalized = Number(value);
44
+ if (!Number.isFinite(normalized) || normalized <= 0) {
45
+ return fallbackValue;
46
+ }
47
+ return Math.floor(normalized);
48
+ }
49
+
50
+ function normalizePropertyName(value, fallbackValue) {
51
+ const normalized = normalizeString(value)
52
+ .replace(/^--?/, "")
53
+ .replace(/^no-/, "")
54
+ .replace(/[^a-zA-Z0-9_-]+/g, "_")
55
+ .replace(/-([a-zA-Z0-9])/g, (_, chr) => chr.toUpperCase())
56
+ .replace(/^_+|_+$/g, "");
57
+ return normalized || fallbackValue;
58
+ }
59
+
60
+ function normalizeToolName(value) {
61
+ const name = normalizeString(value);
62
+ if (!name) return "";
63
+ return name.startsWith(CLI_TOOL_NAME_PREFIX) ? name : `${CLI_TOOL_NAME_PREFIX}${name}`;
64
+ }
65
+
66
+ function registryToolNameToCliPath(toolName) {
67
+ const name = normalizeToolName(toolName);
68
+ const withoutPrefix = name.startsWith(CLI_TOOL_NAME_PREFIX)
69
+ ? name.slice(CLI_TOOL_NAME_PREFIX.length)
70
+ : name;
71
+ return withoutPrefix.split(".").map(normalizeString).filter(Boolean);
72
+ }
73
+
74
+ function optionFlagFromFlags(flags) {
75
+ const text = normalizeString(flags);
76
+ const long = text.match(/--[a-zA-Z0-9][a-zA-Z0-9-]*/);
77
+ if (long) return long[0];
78
+ const short = text.match(/-[a-zA-Z0-9]\b/);
79
+ return short ? short[0] : "";
80
+ }
81
+
82
+ function normalizePositionalSpec(argument = {}, index = 0) {
83
+ return {
84
+ name: normalizePropertyName(argument.name, `arg${index + 1}`),
85
+ required: Boolean(argument.required),
86
+ variadic: Boolean(argument.variadic),
87
+ };
88
+ }
89
+
90
+ function normalizeOptionSpec(option = {}, index = 0) {
91
+ const flag = normalizeString(option.flag || option.long || option.short || optionFlagFromFlags(option.flags));
92
+ const name = normalizePropertyName(option.name || flag, `option${index + 1}`);
93
+ const expectsValue = Boolean(option.expectsValue ?? option.takes_value ?? option.required);
94
+ return {
95
+ name,
96
+ flag,
97
+ negate: Boolean(option.negate ?? option.negated),
98
+ expectsValue,
99
+ variadic: Boolean(option.variadic),
100
+ json: name === "json" || flag === "--json",
101
+ };
102
+ }
103
+
104
+ function normalizeInputSchema(tool = {}) {
105
+ const schema = tool.inputSchema || tool.input_schema || {
106
+ type: "object",
107
+ additionalProperties: false,
108
+ properties: {},
109
+ };
110
+ return {
111
+ ...schema,
112
+ properties: {
113
+ ...(schema.properties || {}),
114
+ timeoutMs: schema.properties?.timeoutMs || {
115
+ type: "integer",
116
+ minimum: 1,
117
+ maximum: MAX_TIMEOUT_MS,
118
+ default: DEFAULT_TIMEOUT_MS,
119
+ description: "MCP bridge execution timeout in milliseconds.",
120
+ },
121
+ },
122
+ };
123
+ }
124
+
125
+ function blockedReasonForCommandPath(commandPathKey) {
126
+ if (BLOCKED_COMMAND_PATHS.has(commandPathKey)) {
127
+ return "blocked_recursive_mcp_server_command";
128
+ }
129
+ if (SENSITIVE_COMMAND_PATHS.has(commandPathKey)) {
130
+ return "blocked_sensitive_cli_command";
131
+ }
132
+ return "";
133
+ }
134
+
135
+ function isPlainObject(value) {
136
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
137
+ }
138
+
139
+ function isBridgeDisabled(env = process.env) {
140
+ const value = normalizeString(env?.[BRIDGE_DISABLED_ENV]).toLowerCase();
141
+ return ["1", "true", "yes", "on"].includes(value);
142
+ }
143
+
144
+ function validateCliPathSegments(cliPath = []) {
145
+ if (!Array.isArray(cliPath) || cliPath.length === 0) {
146
+ return "missing_cli_path";
147
+ }
148
+ for (const segment of cliPath) {
149
+ if (!CLI_PATH_SEGMENT_RE.test(normalizeString(segment))) {
150
+ return "invalid_cli_path_segment";
151
+ }
152
+ }
153
+ return "";
154
+ }
155
+
156
+ function validateCliOptionSpecs(optionSpecs = []) {
157
+ for (const option of optionSpecs) {
158
+ if (!CLI_OPTION_FLAG_RE.test(normalizeString(option.flag))) {
159
+ return "invalid_cli_option_flag";
160
+ }
161
+ }
162
+ return "";
163
+ }
164
+
165
+ function validateBridgeToolDefinition(tool = {}) {
166
+ const metadata = tool.metadata || {};
167
+ return validateCliPathSegments(metadata.cliPath) || validateCliOptionSpecs(metadata.options);
168
+ }
169
+
170
+ function isAllowedInputValue(value) {
171
+ if (value === undefined || value === null) return true;
172
+ if (["string", "number", "boolean"].includes(typeof value)) return true;
173
+ if (Array.isArray(value)) {
174
+ return value.every((item) => ["string", "number", "boolean"].includes(typeof item));
175
+ }
176
+ return false;
177
+ }
178
+
179
+ function validateBridgeToolInput(tool = {}, input = {}) {
180
+ if (!isPlainObject(input)) {
181
+ return "input_must_be_object";
182
+ }
183
+ const properties = new Set(Object.keys(tool.inputSchema?.properties || {}));
184
+ properties.add("timeout_ms");
185
+ for (const [key, value] of Object.entries(input)) {
186
+ if (!properties.has(key)) {
187
+ return `unsupported_input:${key}`;
188
+ }
189
+ if (!isAllowedInputValue(value)) {
190
+ return `unsupported_input_type:${key}`;
191
+ }
192
+ }
193
+ return "";
194
+ }
195
+
196
+ function normalizeRegistryTool(tool = {}) {
197
+ const metadata = tool.metadata || {};
198
+ const cliPath = Array.isArray(metadata.cliPath)
199
+ ? metadata.cliPath.map(normalizeString).filter(Boolean)
200
+ : Array.isArray(metadata.argv)
201
+ ? metadata.argv.map(normalizeString).filter(Boolean)
202
+ : registryToolNameToCliPath(tool.name);
203
+ const commandPathKey = cliPath.join(".");
204
+ const positionalSource = Array.isArray(metadata.positional)
205
+ ? metadata.positional
206
+ : Array.isArray(metadata.arguments)
207
+ ? metadata.arguments
208
+ : [];
209
+ const optionSource = Array.isArray(metadata.options) ? metadata.options : [];
210
+ const options = optionSource.map(normalizeOptionSpec).filter((option) => option.flag);
211
+ const name = normalizeToolName(tool.name || commandPathKey);
212
+ const blockedReason = blockedReasonForCommandPath(commandPathKey);
213
+ const security = {
214
+ ...(tool.security || {}),
215
+ ...(blockedReason ? { runtime_blocked: true, runtime_block_reason: blockedReason } : {}),
216
+ };
217
+
218
+ return {
219
+ name,
220
+ title: tool.title || `sl ${cliPath.join(" ")}`.trim(),
221
+ description: tool.description || `Run SentinelLayer CLI command sl ${cliPath.join(" ")}.`,
222
+ inputSchema: normalizeInputSchema(tool),
223
+ security,
224
+ annotations: {
225
+ ...(tool.annotations || {}),
226
+ destructiveHint: Boolean(security.requires_human_approval),
227
+ readOnlyHint: security.requires_human_approval ? false : undefined,
228
+ },
229
+ metadata: {
230
+ ...metadata,
231
+ bridge: "cli-command",
232
+ cliPath,
233
+ positional: positionalSource.map(normalizePositionalSpec),
234
+ options,
235
+ supportsJson: options.some((option) => option.json),
236
+ blocked: Boolean(metadata.blocked) || Boolean(blockedReason),
237
+ blockedReason: metadata.blockedReason || blockedReason,
238
+ },
239
+ };
240
+ }
241
+
242
+ export async function buildCliCommandMcpTools({
243
+ buildRegistryTemplateFn = buildSentinelayerCliRegistryTemplate,
244
+ buildProgramFn = null,
245
+ generatedAt = "1970-01-01T00:00:00.000Z",
246
+ program = null,
247
+ includeHidden = false,
248
+ } = {}) {
249
+ const registry = await buildRegistryTemplateFn({
250
+ generatedAt,
251
+ program: program || (buildProgramFn ? await buildProgramFn() : null),
252
+ includeHidden,
253
+ });
254
+ return (Array.isArray(registry?.tools) ? registry.tools : [])
255
+ .map(normalizeRegistryTool)
256
+ .filter((tool) => tool.name && tool.metadata.cliPath.length > 0)
257
+ .sort((left, right) => left.name.localeCompare(right.name));
258
+ }
259
+
260
+ function appendPositionalArgs(args, positionalSpecs = [], input = {}) {
261
+ for (const spec of positionalSpecs) {
262
+ const value = input[spec.name];
263
+ if (value === undefined || value === null || value === "") {
264
+ continue;
265
+ }
266
+ if (spec.variadic) {
267
+ const items = Array.isArray(value) ? value : [value];
268
+ args.push(...items.map((item) => String(item)));
269
+ continue;
270
+ }
271
+ args.push(String(value));
272
+ }
273
+ }
274
+
275
+ function appendOptionArgs(args, optionSpecs = [], input = {}) {
276
+ for (const spec of optionSpecs) {
277
+ if (spec.json) {
278
+ continue;
279
+ }
280
+ const value = input[spec.name];
281
+ if (value === undefined || value === null || value === "") {
282
+ continue;
283
+ }
284
+ if (spec.negate) {
285
+ if (value === false) {
286
+ args.push(spec.flag);
287
+ }
288
+ continue;
289
+ }
290
+ if (!spec.expectsValue) {
291
+ if (value === true) {
292
+ args.push(spec.flag);
293
+ }
294
+ continue;
295
+ }
296
+ const values = spec.variadic && Array.isArray(value) ? value : [value];
297
+ for (const item of values) {
298
+ args.push(spec.flag, String(item));
299
+ }
300
+ }
301
+ }
302
+
303
+ export function buildCliCommandArgs(tool = {}, input = {}) {
304
+ const metadata = tool.metadata || {};
305
+ const args = Array.isArray(metadata.cliPath) ? [...metadata.cliPath] : [];
306
+ appendPositionalArgs(args, metadata.positional, input);
307
+ appendOptionArgs(args, metadata.options, input);
308
+ if (metadata.supportsJson && input.json !== false) {
309
+ args.push("--json");
310
+ }
311
+ return args;
312
+ }
313
+
314
+ export function executeCliCommand(
315
+ args,
316
+ {
317
+ targetPath = process.cwd(),
318
+ timeoutMs = DEFAULT_TIMEOUT_MS,
319
+ forceKillGraceMs = 2_000,
320
+ spawnFn = spawn,
321
+ nodePath = process.execPath,
322
+ binPath = DEFAULT_CLI_BIN,
323
+ env = process.env,
324
+ } = {},
325
+ ) {
326
+ return new Promise((resolve) => {
327
+ const child = spawnFn(nodePath, [binPath, ...args], {
328
+ cwd: path.resolve(targetPath),
329
+ env: { ...env, SENTINELAYER_MCP_BRIDGE: "1" },
330
+ windowsHide: true,
331
+ stdio: ["ignore", "pipe", "pipe"],
332
+ });
333
+ let stdout = "";
334
+ let stderr = "";
335
+ let timedOut = false;
336
+ let settled = false;
337
+ let forceKillTimer = null;
338
+ function finish(payload) {
339
+ if (settled) return;
340
+ settled = true;
341
+ clearTimeout(timer);
342
+ if (forceKillTimer) clearTimeout(forceKillTimer);
343
+ resolve(payload);
344
+ }
345
+ const timer = setTimeout(() => {
346
+ timedOut = true;
347
+ try {
348
+ child.kill("SIGTERM");
349
+ } catch {
350
+ // Ignore kill failures; close/error will resolve.
351
+ }
352
+ forceKillTimer = setTimeout(() => {
353
+ try {
354
+ child.kill("SIGKILL");
355
+ } catch {
356
+ // Resolve below even if the process handle refuses the signal.
357
+ }
358
+ finish({
359
+ exitCode: null,
360
+ signal: "SIGKILL",
361
+ timedOut: true,
362
+ stdout,
363
+ stderr: stderr || "process_timeout",
364
+ });
365
+ }, Math.max(1, normalizePositiveInteger(forceKillGraceMs, 2_000)));
366
+ }, Math.max(1, Math.min(MAX_TIMEOUT_MS, normalizePositiveInteger(timeoutMs, DEFAULT_TIMEOUT_MS))));
367
+
368
+ child.stdout?.on("data", (chunk) => {
369
+ stdout += chunk.toString("utf8");
370
+ });
371
+ child.stderr?.on("data", (chunk) => {
372
+ stderr += chunk.toString("utf8");
373
+ });
374
+ child.on("error", (error) => {
375
+ finish({
376
+ exitCode: null,
377
+ signal: null,
378
+ timedOut,
379
+ stdout,
380
+ stderr: stderr || normalizeString(error?.message),
381
+ });
382
+ });
383
+ child.on("close", (exitCode, signal) => {
384
+ finish({
385
+ exitCode,
386
+ signal,
387
+ timedOut,
388
+ stdout,
389
+ stderr,
390
+ });
391
+ });
392
+ });
393
+ }
394
+
395
+ function redactBridgeText(value) {
396
+ return String(value == null ? "" : value)
397
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, `Bearer ${REDACTION_MARKER}`)
398
+ .replace(
399
+ /(["']?\b(?:token|api[_-]?key|secret|password)\b["']?\s*[:=]\s*)["']?[A-Za-z0-9._~+/=-]{12,}["']?/gi,
400
+ `$1${REDACTION_MARKER}`,
401
+ );
402
+ }
403
+
404
+ function redactBridgeValue(value, depth = 0) {
405
+ if (depth > 8) return REDACTION_MARKER;
406
+ if (typeof value === "string") return redactBridgeText(value);
407
+ if (Array.isArray(value)) return value.map((entry) => redactBridgeValue(entry, depth + 1));
408
+ if (value && typeof value === "object") {
409
+ const out = {};
410
+ for (const [key, inner] of Object.entries(value)) {
411
+ if (/(authorization|cookie|set-cookie|x-api-key|api[_-]?key|secret|password|token)/i.test(key)) {
412
+ out[key] = REDACTION_MARKER;
413
+ continue;
414
+ }
415
+ out[key] = redactBridgeValue(inner, depth + 1);
416
+ }
417
+ return out;
418
+ }
419
+ return value;
420
+ }
421
+
422
+ function parseJsonOutput(stdout) {
423
+ const text = normalizeString(stdout);
424
+ if (!text) return null;
425
+ try {
426
+ return redactBridgeValue(JSON.parse(text));
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+
432
+ export function createCliCommandMcpToolHandlers(
433
+ tools = [],
434
+ {
435
+ targetPath = process.cwd(),
436
+ executeCliCommandFn = executeCliCommand,
437
+ env = process.env,
438
+ } = {},
439
+ ) {
440
+ const handlers = {};
441
+ for (const tool of tools) {
442
+ if (tool?.metadata?.bridge !== "cli-command") continue;
443
+ const definitionError = validateBridgeToolDefinition(tool);
444
+ handlers[tool.name] = async (input = {}) => {
445
+ if (isBridgeDisabled(env)) {
446
+ return {
447
+ ok: false,
448
+ reason: "mcp_cli_bridge_disabled",
449
+ tool: tool.name,
450
+ };
451
+ }
452
+ if (definitionError) {
453
+ return {
454
+ ok: false,
455
+ reason: "invalid_cli_bridge_definition",
456
+ detail: definitionError,
457
+ tool: tool.name,
458
+ };
459
+ }
460
+ const inputError = validateBridgeToolInput(tool, input);
461
+ if (inputError) {
462
+ return {
463
+ ok: false,
464
+ reason: "invalid_cli_tool_input",
465
+ detail: inputError,
466
+ tool: tool.name,
467
+ };
468
+ }
469
+ if (tool.metadata.blocked) {
470
+ return {
471
+ ok: false,
472
+ reason: tool.metadata.blockedReason || "blocked_cli_command",
473
+ tool: tool.name,
474
+ };
475
+ }
476
+ const timeoutMs = normalizePositiveInteger(input.timeoutMs || input.timeout_ms, DEFAULT_TIMEOUT_MS);
477
+ const args = buildCliCommandArgs(tool, input);
478
+ const execution = await executeCliCommandFn(args, {
479
+ targetPath,
480
+ timeoutMs,
481
+ env,
482
+ });
483
+ const json = parseJsonOutput(execution.stdout);
484
+ const safeStdout = json ? undefined : redactBridgeText(execution.stdout);
485
+ const safeStderr = redactBridgeText(execution.stderr);
486
+ const safeCommand = redactBridgeText(`sl ${args.join(" ")}`);
487
+ return {
488
+ ok: execution.exitCode === 0 && !execution.timedOut,
489
+ reason: execution.timedOut
490
+ ? "timeout"
491
+ : execution.exitCode === 0
492
+ ? ""
493
+ : "cli_command_failed",
494
+ tool: tool.name,
495
+ command: safeCommand,
496
+ exitCode: execution.exitCode,
497
+ signal: execution.signal,
498
+ timedOut: Boolean(execution.timedOut),
499
+ json,
500
+ stdout: safeStdout || undefined,
501
+ stderr: normalizeString(safeStderr) || undefined,
502
+ };
503
+ };
504
+ }
505
+ return handlers;
506
+ }