@yawlabs/ssh-mcp 0.9.2 → 0.10.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 CHANGED
@@ -66,7 +66,7 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
66
66
 
67
67
  | Tool | Description |
68
68
  |------|-------------|
69
- | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code. |
69
+ | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Subject to [command policy](#command-policy) if configured. |
70
70
  | `ssh_read_file` | Read a file from a remote host via SFTP. |
71
71
  | `ssh_write_file` | Write content to a file on a remote host via SFTP. |
72
72
  | `ssh_upload` | Upload a local file to a remote host via SFTP. |
@@ -79,10 +79,10 @@ Tools that wrap common patterns agents build with ssh_exec — faster and less e
79
79
 
80
80
  | Tool | Description |
81
81
  |------|-------------|
82
- | `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
83
- | `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
82
+ | `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. Subject to [command policy](#command-policy) if configured (policy is checked once before fan-out). |
83
+ | `ssh_find` | Search for files remotely with structured parameters (`name`, `type`, `size`, `depth`, `newer` — match files modified more recently than a reference path). |
84
84
  | `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
85
- | `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
85
+ | `ssh_service_status` | Check systemd service status (active, PID, uptime, description). Flags `isError` only when the unit could not be found / queried, not when an existing unit is intentionally stopped. |
86
86
 
87
87
  ### Auto-diagnostics
88
88
 
@@ -92,6 +92,8 @@ When any remote operation fails, ssh-mcp automatically runs diagnostics and incl
92
92
 
93
93
  Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
94
94
 
95
+ The pool caps at 100 active connections by default. Set `SSH_MCP_MAX_POOL_SIZE=<n>` to raise it for fan-out workloads against many distinct hosts (e.g. `ssh_multi_exec` across a large fleet). When the cap is reached, the pool evicts an idle entry to make room; if every entry is in use it rejects with `Connection pool is full`.
96
+
95
97
  ### SSH config support
96
98
 
97
99
  All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
@@ -110,6 +112,29 @@ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hos
110
112
 
111
113
  The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
112
114
 
115
+ ### Command policy
116
+
117
+ `ssh_exec` and `ssh_multi_exec` accept free-form shell commands from the agent. For security-conscious deployments, you can restrict which commands run via two env vars, each accepting a comma-separated list of regex patterns:
118
+
119
+ - `SSH_MCP_COMMAND_WHITELIST` — if set, the command **must** match at least one pattern, else it's blocked.
120
+ - `SSH_MCP_COMMAND_BLACKLIST` — if set, the command **must not** match any pattern, else it's blocked.
121
+
122
+ When both are set, the command must pass both checks (whitelist first, then blacklist). When neither is set (the default), all commands are allowed.
123
+
124
+ Patterns are JavaScript regexes. Use `^` and `$` for anchored matches; otherwise patterns are treated as substring matches. Commas are the delimiter, so a literal comma in a pattern needs to be expressed as `\x2c` or via a character class.
125
+
126
+ ```bash
127
+ # Read-only allowlist: only ls / df / cat / find / tail
128
+ SSH_MCP_COMMAND_WHITELIST="^ls( .*)?,^df( .*)?,^cat ,^find ,^tail "
129
+
130
+ # Block destructive ops even if your agent goes off-script
131
+ SSH_MCP_COMMAND_BLACKLIST="^rm ,^shutdown,^reboot,^mkfs,^dd if=,>\s*/dev/"
132
+ ```
133
+
134
+ Blocked commands surface as a clear error mentioning which pattern (or which env var) rejected the call, so the agent can adapt rather than guess. Policy is enforced before the SSH connection opens — no remote process is started for a blocked command.
135
+
136
+ The structured higher-level tools (`ssh_find`, `ssh_tail`, `ssh_service_status`, SFTP ops) are exempt from policy. They build commands from typed parameters, so a tight `^ls` whitelist would otherwise force you to allow `^find `, `^tail `, `^systemctl ` just to keep those tools working — defeating the point of a tight whitelist.
137
+
113
138
  ### Windows support
114
139
 
115
140
  On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
package/dist/index.js CHANGED
@@ -343,6 +343,13 @@ function ensureAgent() {
343
343
  if (process.platform === "win32") {
344
344
  const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
345
345
  if (result) return result;
346
+ return {
347
+ running: false,
348
+ reachable: false,
349
+ keys: [],
350
+ started: false,
351
+ message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
352
+ };
346
353
  }
347
354
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
348
355
  if (ok) {
@@ -370,7 +377,7 @@ function ensureAgent() {
370
377
  reachable: false,
371
378
  keys: [],
372
379
  started: false,
373
- message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
380
+ message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
374
381
  };
375
382
  }
376
383
  function detectKeyType(filePath, fileName) {
@@ -1220,6 +1227,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1220
1227
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1221
1228
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1222
1229
  const sinceMatch = raw.match(/since\s+(.+?);/);
1230
+ const unknown = !activeMatch && result.code !== 0;
1223
1231
  const fallbackStatus = result.code === 0 ? "active" : "inactive";
1224
1232
  return {
1225
1233
  name: serviceName,
@@ -1228,10 +1236,41 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1228
1236
  description: descMatch?.[1]?.trim(),
1229
1237
  since: sinceMatch?.[1]?.trim(),
1230
1238
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
1231
- raw
1239
+ raw,
1240
+ unknown
1232
1241
  };
1233
1242
  }
1234
1243
 
1244
+ // src/policy.ts
1245
+ function parsePatterns(raw) {
1246
+ if (!raw) return [];
1247
+ const patterns = [];
1248
+ for (const p of raw.split(",")) {
1249
+ const cleaned = p.replace(/^\s+/, "");
1250
+ if (!cleaned) continue;
1251
+ try {
1252
+ patterns.push(new RegExp(cleaned));
1253
+ } catch {
1254
+ console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1255
+ }
1256
+ }
1257
+ return patterns;
1258
+ }
1259
+ function enforcePolicy(command) {
1260
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1261
+ if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1262
+ throw new Error(
1263
+ `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1264
+ );
1265
+ }
1266
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1267
+ for (const pattern of blacklist) {
1268
+ if (pattern.test(command)) {
1269
+ throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1270
+ }
1271
+ }
1272
+ }
1273
+
1235
1274
  // src/tools.ts
1236
1275
  var HostSchema = z.string().describe("SSH hostname or IP address");
1237
1276
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
@@ -1252,19 +1291,21 @@ function registerTools(server, pool) {
1252
1291
  const connectionPool = pool ?? new ConnectionPool();
1253
1292
  server.tool(
1254
1293
  "ssh_exec",
1255
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1294
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1256
1295
  {
1257
1296
  ...connectionParams,
1258
1297
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1259
1298
  timeout: TimeoutSchema
1260
1299
  },
1261
1300
  async ({ command, timeout, ...conn }) => {
1301
+ enforcePolicy(command);
1262
1302
  return connectionPool.withConnection(conn, async (client) => {
1263
1303
  const result = await exec(client, command, timeout || 3e4);
1264
1304
  const parts = [];
1265
1305
  if (result.stdout) parts.push(result.stdout);
1266
1306
  if (result.stderr) parts.push(`[stderr]
1267
1307
  ${result.stderr}`);
1308
+ if (result.signal) parts.push(`[signal: ${result.signal}]`);
1268
1309
  parts.push(`[exit code: ${result.code}]`);
1269
1310
  return { content: [{ type: "text", text: parts.join("\n") }] };
1270
1311
  });
@@ -1501,7 +1542,7 @@ ${result.stderr}`);
1501
1542
  );
1502
1543
  server.tool(
1503
1544
  "ssh_multi_exec",
1504
- "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
1545
+ "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
1505
1546
  {
1506
1547
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1507
1548
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1512,6 +1553,7 @@ ${result.stderr}`);
1512
1553
  timeout: TimeoutSchema
1513
1554
  },
1514
1555
  async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1556
+ enforcePolicy(command);
1515
1557
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1516
1558
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1517
1559
  const lines = [];
@@ -1601,7 +1643,7 @@ ${files.join("\n")}` }] };
1601
1643
  if (status.since) lines.push(`Since: ${status.since}`);
1602
1644
  lines.push("");
1603
1645
  lines.push(status.raw);
1604
- return { content: [{ type: "text", text: lines.join("\n") }] };
1646
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
1605
1647
  });
1606
1648
  }
1607
1649
  );
package/dist/server.d.ts CHANGED
@@ -175,11 +175,29 @@ interface ServiceStatus {
175
175
  since?: string;
176
176
  pid?: number;
177
177
  raw: string;
178
+ /**
179
+ * True when systemctl could not report on the unit at all: no `Active:` line
180
+ * parseable AND non-zero exit. Typical causes: typo'd unit name, unit file
181
+ * doesn't exist, systemd unreachable. Distinct from "service exists but is
182
+ * stopped" (active=false but unknown=false).
183
+ */
184
+ unknown: boolean;
178
185
  }
179
186
  declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
180
187
 
188
+ /**
189
+ * Check a command against the env-configured policy. Throws if blocked.
190
+ *
191
+ * Called from MCP tool handlers (ssh_exec, ssh_multi_exec) -- not from `exec()` itself,
192
+ * so library consumers using the programmatic API don't get policy enforcement (they're
193
+ * outside the MCP trust boundary and write their own gating).
194
+ */
195
+ declare function enforcePolicy(command: string): void;
196
+ /** Returns true if any policy is currently configured. Used by tool descriptions to surface that. */
197
+ declare function isPolicyConfigured(): boolean;
198
+
181
199
  declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
182
200
 
183
201
  declare function createServer(pool?: ConnectionPool): McpServer;
184
202
 
185
- export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
203
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -339,6 +339,13 @@ function ensureAgent() {
339
339
  if (process.platform === "win32") {
340
340
  const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
341
341
  if (result) return result;
342
+ return {
343
+ running: false,
344
+ reachable: false,
345
+ keys: [],
346
+ started: false,
347
+ message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
348
+ };
342
349
  }
343
350
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
344
351
  if (ok) {
@@ -366,7 +373,7 @@ function ensureAgent() {
366
373
  reachable: false,
367
374
  keys: [],
368
375
  started: false,
369
- message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
376
+ message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
370
377
  };
371
378
  }
372
379
  function detectKeyType(filePath, fileName) {
@@ -1031,6 +1038,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1031
1038
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1032
1039
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1033
1040
  const sinceMatch = raw.match(/since\s+(.+?);/);
1041
+ const unknown = !activeMatch && result.code !== 0;
1034
1042
  const fallbackStatus = result.code === 0 ? "active" : "inactive";
1035
1043
  return {
1036
1044
  name: serviceName,
@@ -1039,10 +1047,44 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1039
1047
  description: descMatch?.[1]?.trim(),
1040
1048
  since: sinceMatch?.[1]?.trim(),
1041
1049
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
1042
- raw
1050
+ raw,
1051
+ unknown
1043
1052
  };
1044
1053
  }
1045
1054
 
1055
+ // src/policy.ts
1056
+ function parsePatterns(raw) {
1057
+ if (!raw) return [];
1058
+ const patterns = [];
1059
+ for (const p of raw.split(",")) {
1060
+ const cleaned = p.replace(/^\s+/, "");
1061
+ if (!cleaned) continue;
1062
+ try {
1063
+ patterns.push(new RegExp(cleaned));
1064
+ } catch {
1065
+ console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1066
+ }
1067
+ }
1068
+ return patterns;
1069
+ }
1070
+ function enforcePolicy(command) {
1071
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1072
+ if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1073
+ throw new Error(
1074
+ `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1075
+ );
1076
+ }
1077
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1078
+ for (const pattern of blacklist) {
1079
+ if (pattern.test(command)) {
1080
+ throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1081
+ }
1082
+ }
1083
+ }
1084
+ function isPolicyConfigured() {
1085
+ return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
1086
+ }
1087
+
1046
1088
  // src/pool.ts
1047
1089
  function defaultMaxPoolSize() {
1048
1090
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
@@ -1257,19 +1299,21 @@ function registerTools(server, pool) {
1257
1299
  const connectionPool = pool ?? new ConnectionPool();
1258
1300
  server.tool(
1259
1301
  "ssh_exec",
1260
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1302
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1261
1303
  {
1262
1304
  ...connectionParams,
1263
1305
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1264
1306
  timeout: TimeoutSchema
1265
1307
  },
1266
1308
  async ({ command, timeout, ...conn }) => {
1309
+ enforcePolicy(command);
1267
1310
  return connectionPool.withConnection(conn, async (client) => {
1268
1311
  const result = await exec(client, command, timeout || 3e4);
1269
1312
  const parts = [];
1270
1313
  if (result.stdout) parts.push(result.stdout);
1271
1314
  if (result.stderr) parts.push(`[stderr]
1272
1315
  ${result.stderr}`);
1316
+ if (result.signal) parts.push(`[signal: ${result.signal}]`);
1273
1317
  parts.push(`[exit code: ${result.code}]`);
1274
1318
  return { content: [{ type: "text", text: parts.join("\n") }] };
1275
1319
  });
@@ -1506,7 +1550,7 @@ ${result.stderr}`);
1506
1550
  );
1507
1551
  server.tool(
1508
1552
  "ssh_multi_exec",
1509
- "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
1553
+ "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
1510
1554
  {
1511
1555
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1512
1556
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1517,6 +1561,7 @@ ${result.stderr}`);
1517
1561
  timeout: TimeoutSchema
1518
1562
  },
1519
1563
  async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1564
+ enforcePolicy(command);
1520
1565
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1521
1566
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1522
1567
  const lines = [];
@@ -1606,7 +1651,7 @@ ${files.join("\n")}` }] };
1606
1651
  if (status.since) lines.push(`Since: ${status.since}`);
1607
1652
  lines.push("");
1608
1653
  lines.push(status.raw);
1609
- return { content: [{ type: "text", text: lines.join("\n") }] };
1654
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
1610
1655
  });
1611
1656
  }
1612
1657
  );
@@ -1638,11 +1683,13 @@ export {
1638
1683
  createServer,
1639
1684
  diagnose,
1640
1685
  downloadFile,
1686
+ enforcePolicy,
1641
1687
  ensureAgent,
1642
1688
  exec,
1643
1689
  find,
1644
1690
  fixKnownHosts,
1645
1691
  formatDiagnostics,
1692
+ isPolicyConfigured,
1646
1693
  listDir,
1647
1694
  listSshKeys,
1648
1695
  loadKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {