machine-bridge-mcp 0.18.0 → 1.0.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 (47) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +2 -0
  4. package/SECURITY.md +8 -6
  5. package/browser-extension/manifest.json +2 -2
  6. package/browser-extension/page-automation.js +0 -1
  7. package/docs/AGENT_CONTEXT.md +2 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/AUDIT.md +23 -0
  10. package/docs/ENGINEERING.md +3 -1
  11. package/docs/GETTING_STARTED.md +3 -0
  12. package/docs/LOCAL_AUTOMATION.md +1 -1
  13. package/docs/OPERATIONS.md +21 -1
  14. package/docs/PROJECT_STANDARDS.md +1 -1
  15. package/docs/TESTING.md +6 -2
  16. package/package.json +6 -3
  17. package/scripts/privacy-check.mjs +13 -13
  18. package/scripts/sarif-security-gate.mjs +146 -0
  19. package/src/local/account-access.mjs +0 -1
  20. package/src/local/atomic-fs.mjs +1 -1
  21. package/src/local/browser-bridge.mjs +2 -2
  22. package/src/local/browser-extension-protocol.mjs +1 -1
  23. package/src/local/browser-pairing-store.mjs +3 -4
  24. package/src/local/cli.mjs +21 -55
  25. package/src/local/default-instructions.mjs +4 -3
  26. package/src/local/errors.mjs +1 -5
  27. package/src/local/exclusive-file.mjs +9 -3
  28. package/src/local/full-access-test.mjs +5 -5
  29. package/src/local/job-runner.mjs +1 -1
  30. package/src/local/managed-jobs.mjs +27 -51
  31. package/src/local/relay-connection.mjs +7 -0
  32. package/src/local/runtime.mjs +20 -6
  33. package/src/local/secure-file.mjs +89 -10
  34. package/src/local/service-convergence.mjs +20 -0
  35. package/src/local/service.mjs +30 -38
  36. package/src/local/shell.mjs +26 -7
  37. package/src/local/ssh-key.mjs +71 -45
  38. package/src/local/state.mjs +46 -56
  39. package/src/local/worker-secret-file.mjs +130 -0
  40. package/src/local/workspace-file-service.mjs +10 -2
  41. package/src/shared/server-metadata.json +2 -3
  42. package/src/worker/http.ts +7 -8
  43. package/src/worker/index.ts +31 -41
  44. package/src/worker/mcp-session.ts +72 -0
  45. package/src/worker/oauth-state.ts +1 -1
  46. package/src/worker/pending-calls.ts +36 -5
  47. package/src/worker/tool-timeout.ts +18 -0
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
3
3
  import { lstat, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { RelayConnection } from "./relay-connection.mjs";
7
- import { MAX_COMMAND_BYTES, ProcessSessionManager } from "./process-sessions.mjs";
7
+ import { ProcessSessionManager } from "./process-sessions.mjs";
8
8
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
9
- import { allToolNames, assertCanonicalFullPolicy, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, PolicyGate, SERVER_NAME } from "./tools.mjs";
9
+ import { allToolNames, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, PolicyGate, SERVER_NAME } from "./tools.mjs";
10
10
  import { publicError } from "./errors.mjs";
11
11
  import { ProcessTracker } from "./process-tracker.mjs";
12
12
  import { CallRegistry } from "./call-registry.mjs";
@@ -367,9 +367,15 @@ export class LocalRuntime {
367
367
  const envelope = normalizeRelayToolCall(message);
368
368
  if (!envelope.ok) {
369
369
  this.logger.event?.("warn", "relay.tool_call.invalid", { has_call_id: Boolean(envelope.id) });
370
- if (envelope.id) this.send({ type: "tool_result", id: envelope.id, ok: false, error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false } });
370
+ if (envelope.id) this.deliverRelayToolResult({
371
+ type: "tool_result",
372
+ id: envelope.id,
373
+ ok: false,
374
+ error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
375
+ });
371
376
  return;
372
377
  }
378
+ let response;
373
379
  try {
374
380
  const result = await this.executeTool(envelope.tool, envelope.arguments, {
375
381
  callId: envelope.id,
@@ -377,10 +383,18 @@ export class LocalRuntime {
377
383
  timeoutMs: envelope.timeoutMs,
378
384
  authorization: envelope.authorization,
379
385
  });
380
- this.send({ type: "tool_result", id: envelope.id, ok: true, result });
386
+ response = { type: "tool_result", id: envelope.id, ok: true, result };
381
387
  } catch (error) {
382
- this.send({ type: "tool_result", id: envelope.id, ok: false, error: publicError(error) });
388
+ response = { type: "tool_result", id: envelope.id, ok: false, error: publicError(error) };
383
389
  }
390
+ this.deliverRelayToolResult(response);
391
+ }
392
+
393
+ deliverRelayToolResult(response) {
394
+ if (this.send(response)) return true;
395
+ this.logger.event?.("warn", "relay.tool_result.delivery_failed", { call_id: shortCallId(response?.id) });
396
+ this.relay?.interrupt?.("relay_transport_error");
397
+ return false;
384
398
  }
385
399
 
386
400
  finishCall(callId) {
@@ -1,17 +1,50 @@
1
- import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
2
2
 
3
- export function readBoundedRegularFileSync(file, maxBytes) {
4
- return readBoundedRegularFileWithInfoSync(file, maxBytes).buffer;
3
+ export function openRegularFileSync(file, flags, options = {}) {
4
+ const mode = Number.isInteger(options.mode) ? options.mode : undefined;
5
+ const label = String(options.label || "path");
6
+ const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
7
+ let fd;
8
+ try {
9
+ fd = mode === undefined
10
+ ? openSync(file, Number(flags) | noFollow)
11
+ : openSync(file, Number(flags) | noFollow, mode);
12
+ } catch (error) {
13
+ if (error?.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`, { cause: error });
14
+ throw error;
15
+ }
16
+ try {
17
+ const info = fstatSync(fd);
18
+ if (!info.isFile()) throw new Error(`${label} is not a regular file`);
19
+ if (Number.isInteger(options.chmod)) setDescriptorMode(fd, options.chmod);
20
+ return { fd, info };
21
+ } catch (error) {
22
+ closeSync(fd);
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ function withRegularFileSync(file, flags, options, callback) {
28
+ const opened = openRegularFileSync(file, flags, options);
29
+ try {
30
+ return callback(opened.fd, opened.info);
31
+ } finally {
32
+ closeSync(opened.fd);
33
+ }
34
+ }
35
+
36
+ export function chmodRegularFileSync(file, mode, label = "path") {
37
+ return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
38
+ }
39
+
40
+ export function readBoundedRegularFileSync(file, maxBytes, label = "path") {
41
+ return readBoundedRegularFileWithInfoSync(file, maxBytes, label).buffer;
5
42
  }
6
43
 
7
- export function readBoundedRegularFileWithInfoSync(file, maxBytes) {
44
+ export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path") {
8
45
  const limit = Number(maxBytes);
9
46
  if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
10
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
11
- const fd = openSync(file, flags);
12
- try {
13
- const info = fstatSync(fd);
14
- if (!info.isFile()) throw new Error("path is not a regular file");
47
+ return withRegularFileSync(file, fsConstants.O_RDONLY, { label }, (fd, info) => {
15
48
  if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
16
49
  const buffer = Buffer.alloc(info.size);
17
50
  let offset = 0;
@@ -21,7 +54,53 @@ export function readBoundedRegularFileWithInfoSync(file, maxBytes) {
21
54
  offset += count;
22
55
  }
23
56
  return { buffer: buffer.subarray(0, offset), info };
57
+ });
58
+ }
59
+
60
+ export function ensureOwnerOnlyDirectorySync(dir, options = {}) {
61
+ const platform = String(options.platform || process.platform);
62
+ const makeDirectory = options.mkdirSync || mkdirSync;
63
+ const inspectPath = options.lstatSync || lstatSync;
64
+ makeDirectory(dir, { recursive: true, mode: 0o700 });
65
+ if (platform === "win32") {
66
+ const info = inspectPath(dir);
67
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("owner-only path must be a real directory");
68
+ return info;
69
+ }
70
+
71
+ const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
72
+ const directoryOnly = Number(fsConstants.O_DIRECTORY || 0);
73
+ if (!noFollow || !directoryOnly) throw new Error("secure owner-only directory descriptors are unavailable on this platform");
74
+ const open = options.openSync || openSync;
75
+ const inspectDescriptor = options.fstatSync || fstatSync;
76
+ const restrictDescriptor = options.fchmodSync || fchmodSync;
77
+ const close = options.closeSync || closeSync;
78
+ let fd;
79
+ try {
80
+ fd = open(dir, Number(fsConstants.O_RDONLY) | noFollow | directoryOnly);
81
+ } catch (error) {
82
+ if (["ELOOP", "ENOTDIR"].includes(error?.code)) throw new Error("owner-only path must be a real directory and not a symbolic link", { cause: error });
83
+ throw error;
84
+ }
85
+ try {
86
+ let info = inspectDescriptor(fd);
87
+ if (!info.isDirectory()) throw new Error("owner-only path must be a directory");
88
+ try {
89
+ restrictDescriptor(fd, 0o700);
90
+ } catch (error) {
91
+ throw new Error("could not restrict owner-only directory permissions", { cause: error });
92
+ }
93
+ info = inspectDescriptor(fd);
94
+ if (!info.isDirectory()) throw new Error("owner-only directory identity changed during permission enforcement");
95
+ if ((info.mode & 0o077) !== 0) throw new Error("owner-only directory remains accessible to group or other users");
96
+ return info;
24
97
  } finally {
25
- closeSync(fd);
98
+ if (fd !== undefined) close(fd);
99
+ }
100
+ }
101
+
102
+ function setDescriptorMode(fd, mode) {
103
+ try { fchmodSync(fd, mode); } catch (error) {
104
+ if (process.platform !== "win32") throw error;
26
105
  }
27
106
  }
@@ -0,0 +1,20 @@
1
+ const DEFAULT_ATTEMPTS = 20;
2
+ const DEFAULT_DELAY_MS = 100;
3
+
4
+ export async function waitForInactiveStatus(
5
+ readStatus,
6
+ { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
7
+ ) {
8
+ if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
9
+ const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
10
+ let status = await readStatus();
11
+ for (let attempt = 1; status?.active === true && attempt < maximum; attempt += 1) {
12
+ await sleep(delayMs);
13
+ status = await readStatus();
14
+ }
15
+ return status;
16
+ }
17
+
18
+ function delay(milliseconds) {
19
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
20
+ }
@@ -1,10 +1,11 @@
1
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, mkdirSync, readSync, realpathSync, rmSync, statSync, writeSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { run } from "./shell.mjs";
5
- import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
4
+ import { runExecutable } from "./shell.mjs";
5
+ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
6
6
  import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
- import { readBoundedRegularFileSync } from "./secure-file.mjs";
7
+ import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
+ import { waitForInactiveStatus } from "./service-convergence.mjs";
8
9
 
9
10
  const LABEL = "dev.machine-bridge-mcp.daemon";
10
11
  const WINDOWS_TASK = "MachineBridgeMCP";
@@ -12,7 +13,11 @@ const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
12
13
  const AUTOSTART_LOG_SCHEMA_VERSION = 3;
13
14
 
14
15
  function serviceRun(command, args) {
15
- return run(command, args, {
16
+ return runServiceCommand(command, args);
17
+ }
18
+
19
+ export function runServiceCommand(command, args, execute = runExecutable) {
20
+ return execute(command, args, {
16
21
  capture: true,
17
22
  allowFailure: true,
18
23
  maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
@@ -33,9 +38,9 @@ export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
33
38
  }
34
39
 
35
40
  export async function autostartStatus({ logger = console } = {}) {
36
- if (process.platform === "darwin") return statusLaunchd(logger);
37
- if (process.platform === "win32") return statusWindowsTask(logger);
38
- return statusSystemd(logger);
41
+ if (process.platform === "darwin") return statusLaunchd();
42
+ if (process.platform === "win32") return statusWindowsTask();
43
+ return statusSystemd();
39
44
  }
40
45
 
41
46
  export async function startAutostart({ logger = console } = {}) {
@@ -81,21 +86,18 @@ export function trimAutostartLogs(stateRoot, options = {}) {
81
86
  const file = path.join(logs, name);
82
87
  let fd;
83
88
  try {
84
- if (!existsSync(file)) continue;
85
- const before = lstatSync(file);
86
- if (before.isSymbolicLink() || !before.isFile()) continue;
87
- const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
88
- fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
89
- const info = fstatSync(fd);
90
- if (!info.isFile()) continue;
89
+ const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
90
+ label: "autostart log path",
91
+ chmod: 0o600,
92
+ });
93
+ fd = opened.fd;
91
94
  if (reset) {
92
95
  ftruncateSync(fd, 0);
93
- } else if (info.size > maxBytes) {
94
- const tail = readLogTail(fd, info.size, keepBytes);
96
+ } else if (opened.info.size > maxBytes) {
97
+ const tail = readLogTail(fd, opened.info.size, keepBytes);
95
98
  ftruncateSync(fd, 0);
96
99
  if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
97
100
  }
98
- try { chmodSync(file, 0o600); } catch {}
99
101
  } catch {
100
102
  // Log maintenance is best effort and must not stop daemon startup.
101
103
  } finally {
@@ -212,28 +214,17 @@ export function daemonArgs(spec) {
212
214
  }
213
215
 
214
216
  function ensurePrivateLogFile(file) {
215
- if (existsSync(file)) {
216
- const info = lstatSync(file);
217
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart log path must be a regular non-symbolic-link file");
218
- }
219
- const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
220
- const fd = openSync(file, Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | noFollow, 0o600);
221
- try {
222
- if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
223
- } finally {
224
- closeSync(fd);
225
- }
226
- ownerOnlyFile(file);
217
+ const opened = openRegularFileSync(
218
+ file,
219
+ Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
220
+ { label: "autostart log path", mode: 0o600, chmod: 0o600 },
221
+ );
222
+ closeSync(opened.fd);
227
223
  }
228
224
 
229
225
  function writePrivateServiceFile(file, content) {
230
226
  mkdirSync(path.dirname(file), { recursive: true });
231
- if (existsSync(file)) {
232
- const info = lstatSync(file);
233
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
234
- }
235
227
  replaceFileAtomicallySync(file, content, { mode: 0o600 });
236
- ownerOnlyFile(file);
237
228
  }
238
229
 
239
230
  function launchdPlistPath() {
@@ -299,9 +290,10 @@ async function stopLaunchd(logger) {
299
290
  const byPlist = byServiceTarget.code === 0
300
291
  ? null
301
292
  : await serviceRun("launchctl", ["bootout", domainTarget, plistPath]);
302
- const after = await statusLaunchd();
293
+ const after = await waitForInactiveStatus(statusLaunchd);
303
294
  const rawResult = byPlist || byServiceTarget;
304
- const ok = !after.active;
295
+ const active = after?.active !== false;
296
+ const ok = !active;
305
297
  if (ok) logger.info?.("launchd service stopped");
306
298
  else logger.warn?.("launchd service is still active after the stop request");
307
299
  return {
@@ -310,7 +302,7 @@ async function stopLaunchd(logger) {
310
302
  provider: "launchd",
311
303
  installed: existsSync(plistPath),
312
304
  active_before: true,
313
- active: after.active,
305
+ active,
314
306
  already_stopped: false,
315
307
  code: ok ? 0 : rawResult.code,
316
308
  bootout_service_target: byServiceTarget,
@@ -4,11 +4,13 @@ import path from "node:path";
4
4
  import { packageRoot } from "./state.mjs";
5
5
  import { BoundedOutput } from "./bounded-output.mjs";
6
6
 
7
- export function run(command, args = [], options = {}) {
7
+ export function runExecutable(command, args = [], options = {}) {
8
+ const executable = validateExecutable(command);
9
+ const argv = validateExecutableArgs(args);
8
10
  return new Promise((resolve, reject) => {
9
11
  const capture = Boolean(options.capture);
10
12
  const maxOutputBytes = Number.isFinite(Number(options.maxOutputBytes)) ? Math.max(1024, Number(options.maxOutputBytes)) : 2 * 1024 * 1024;
11
- const child = spawn(command, args, {
13
+ const child = spawn(executable, argv, {
12
14
  cwd: options.cwd || process.cwd(),
13
15
  env: options.env || process.env,
14
16
  stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
@@ -54,7 +56,7 @@ export function run(command, args = [], options = {}) {
54
56
  };
55
57
  if ((!timedOut && code === 0) || options.allowFailure) resolve(result);
56
58
  else {
57
- const error = new Error((result.stderr || result.stdout || `${command} exited ${result.code}`).trim());
59
+ const error = new Error((result.stderr || result.stdout || `${executable} exited ${result.code}`).trim());
58
60
  error.result = result;
59
61
  reject(error);
60
62
  }
@@ -63,6 +65,25 @@ export function run(command, args = [], options = {}) {
63
65
  }
64
66
 
65
67
 
68
+ function validateExecutable(value) {
69
+ if (typeof value !== "string" || !value || value.includes("\0")) {
70
+ throw new TypeError("executable must be a non-empty string without NUL bytes");
71
+ }
72
+ if (Buffer.byteLength(value) > 32 * 1024) throw new RangeError("executable path exceeds 32 KiB");
73
+ return value;
74
+ }
75
+
76
+ function validateExecutableArgs(value) {
77
+ if (!Array.isArray(value) || value.length > 4096) throw new TypeError("executable arguments must be an array with at most 4096 entries");
78
+ let totalBytes = 0;
79
+ return value.map((entry) => {
80
+ if (typeof entry !== "string" || entry.includes("\0")) throw new TypeError("executable arguments must be strings without NUL bytes");
81
+ totalBytes += Buffer.byteLength(entry);
82
+ if (totalBytes > 1024 * 1024) throw new RangeError("executable arguments exceed 1 MiB");
83
+ return entry;
84
+ });
85
+ }
86
+
66
87
  function terminateCommandTree(child, force) {
67
88
  if (!child?.pid) return;
68
89
  if (process.platform === "win32") {
@@ -94,16 +115,14 @@ function capturedResult(code, stdout, stderr, extraStderr = "") {
94
115
 
95
116
  function findWranglerCommand() {
96
117
  const suffix = process.platform === "win32" ? ".cmd" : "";
97
- const local = path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`);
98
- if (existsSync(local)) return { cmd: local, argsPrefix: [] };
99
- throw new Error("Wrangler dependency is not installed. Run `npm install` in the package/source directory and retry.");
118
+ return { cmd: path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`), argsPrefix: [] };
100
119
  }
101
120
 
102
121
  export async function runWrangler(args, options = {}) {
103
122
  const wrangler = findWranglerCommand();
104
123
  const operation = String(args[0] || "");
105
124
  const timeoutMs = options.timeoutMs ?? (operation === "login" || operation === "deploy" ? 10 * 60 * 1000 : 2 * 60 * 1000);
106
- return run(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
125
+ return runExecutable(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
107
126
  }
108
127
 
109
128
  export function workspaceShellCommand(command) {
@@ -1,8 +1,10 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
- import { chmod, copyFile, link, lstat, mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
3
- import { basename, dirname, resolve } from "node:path";
4
- import { randomBytes } from "node:crypto";
5
- import { run } from "./shell.mjs";
2
+ import { chmod, copyFile, link, mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ import { runExecutable } from "./shell.mjs";
7
+ import { chmodRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
6
8
 
7
9
  const KEY_TYPES = new Set(["ed25519", "rsa"]);
8
10
 
@@ -35,11 +37,10 @@ function normalizeKeyRequest(options) {
35
37
  }
36
38
 
37
39
  async function inspectExistingKeyFiles(privateKeyPath, publicKeyPath) {
38
- const privateInfo = await safeLstat(privateKeyPath);
39
- const publicInfo = await safeLstat(publicKeyPath);
40
- if (privateInfo?.isSymbolicLink() || publicInfo?.isSymbolicLink()) throw new Error("SSH key path must not be a symbolic link");
41
- if (!privateInfo && !publicInfo) return false;
42
- if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
40
+ const privateSnapshot = tryReadKeySnapshot(privateKeyPath, 1024 * 1024, "SSH private key");
41
+ const publicSnapshot = tryReadKeySnapshot(publicKeyPath, 64 * 1024, "SSH public key");
42
+ if (!privateSnapshot && !publicSnapshot) return false;
43
+ if (!privateSnapshot || !publicSnapshot) throw new Error("SSH key pair is incomplete or not a pair of regular files");
43
44
  return true;
44
45
  }
45
46
 
@@ -51,7 +52,7 @@ async function createSshKeyPair(request) {
51
52
  const args = ["-q", "-t", request.type];
52
53
  if (request.type === "rsa") args.push("-b", String(normalizeRsaBits(request.bits)));
53
54
  args.push("-N", "", "-f", tempPrivate, "-C", request.comment);
54
- const generated = await run("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
55
+ const generated = await runExecutable("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
55
56
  if (generated.code !== 0) throw new Error("ssh-keygen failed");
56
57
  await secureKeyModes(tempPrivate, tempPublic);
57
58
  await installNoReplace(tempPrivate, request.privateKeyPath);
@@ -70,37 +71,47 @@ async function createSshKeyPair(request) {
70
71
  }
71
72
 
72
73
  export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${privateKeyPath}.pub`, created = false) {
73
- const privateInfo = await stat(privateKeyPath);
74
- const publicInfo = await stat(publicKeyPath);
75
- if (!privateInfo.isFile() || !publicInfo.isFile()) throw new Error("SSH key pair is not composed of regular files");
76
- const derived = await run("ssh-keygen", ["-y", "-P", "", "-f", privateKeyPath], {
77
- capture: true,
78
- allowFailure: true,
79
- timeoutMs: 15_000,
80
- maxOutputBytes: 64 * 1024,
81
- });
82
- if (derived.code !== 0) throw new Error("SSH private key cannot be used non-interactively or is invalid");
83
- const publicLine = (await readFile(publicKeyPath, "utf8")).trim();
84
- if (!/^(ssh-ed25519|ssh-rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(publicLine)) throw new Error("generated SSH public key is invalid");
85
- const expectedFields = publicLine.split(/\s+/).slice(0, 2).join(" ");
86
- const derivedFields = derived.stdout.trim().split(/\s+/).slice(0, 2).join(" ");
87
- if (expectedFields !== derivedFields) throw new Error("SSH public key does not match the private key");
88
- const fingerprint = await run("ssh-keygen", ["-lf", publicKeyPath, "-E", "sha256"], {
89
- capture: true,
90
- timeoutMs: 15_000,
91
- maxOutputBytes: 64 * 1024,
92
- });
93
- const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
94
- if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
95
- return {
96
- created,
97
- privateKeyPath: resolve(privateKeyPath),
98
- publicKeyPath: resolve(publicKeyPath),
99
- privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
100
- publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
101
- fingerprint: fingerprintValue,
102
- publicKeyType: publicLine.split(/\s+/, 1)[0],
103
- };
74
+ const privateSnapshot = readKeySnapshot(privateKeyPath, 1024 * 1024, "SSH private key");
75
+ const publicSnapshot = readKeySnapshot(publicKeyPath, 64 * 1024, "SSH public key");
76
+ const inspectionRoot = await mkdtemp(join(tmpdir(), "machine-mcp-key-inspection-"));
77
+ const inspectionPrivate = join(inspectionRoot, "key");
78
+ const inspectionPublic = `${inspectionPrivate}.pub`;
79
+ try {
80
+ await writeFile(inspectionPrivate, privateSnapshot.buffer, { mode: 0o600, flag: "wx" });
81
+ await writeFile(inspectionPublic, publicSnapshot.buffer, { mode: 0o600, flag: "wx" });
82
+ const derived = await runExecutable("ssh-keygen", ["-y", "-P", "", "-f", inspectionPrivate], {
83
+ capture: true,
84
+ allowFailure: true,
85
+ timeoutMs: 15_000,
86
+ maxOutputBytes: 64 * 1024,
87
+ });
88
+ if (derived.code !== 0) throw new Error("SSH private key cannot be used non-interactively or is invalid");
89
+ const publicLine = new TextDecoder("utf-8", { fatal: true }).decode(publicSnapshot.buffer).trim();
90
+ if (!/^(ssh-ed25519|ssh-rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(publicLine)) throw new Error("generated SSH public key is invalid");
91
+ const expectedFields = publicLine.split(/\s+/).slice(0, 2).join(" ");
92
+ const derivedFields = derived.stdout.trim().split(/\s+/).slice(0, 2).join(" ");
93
+ if (expectedFields !== derivedFields) throw new Error("SSH public key does not match the private key");
94
+ const fingerprint = await runExecutable("ssh-keygen", ["-lf", inspectionPublic, "-E", "sha256"], {
95
+ capture: true,
96
+ timeoutMs: 15_000,
97
+ maxOutputBytes: 64 * 1024,
98
+ });
99
+ const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
100
+ if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
101
+ assertKeySnapshotCurrent(privateKeyPath, privateSnapshot, 1024 * 1024, "SSH private key");
102
+ assertKeySnapshotCurrent(publicKeyPath, publicSnapshot, 64 * 1024, "SSH public key");
103
+ return {
104
+ created,
105
+ privateKeyPath: resolve(privateKeyPath),
106
+ publicKeyPath: resolve(publicKeyPath),
107
+ privateMode: process.platform === "win32" ? null : `0${(privateSnapshot.info.mode & 0o777).toString(8)}`,
108
+ publicMode: process.platform === "win32" ? null : `0${(publicSnapshot.info.mode & 0o777).toString(8)}`,
109
+ fingerprint: fingerprintValue,
110
+ publicKeyType: publicLine.split(/\s+/, 1)[0],
111
+ };
112
+ } finally {
113
+ await rm(inspectionRoot, { recursive: true, force: true });
114
+ }
104
115
  }
105
116
 
106
117
  async function installNoReplace(source, target) {
@@ -120,17 +131,32 @@ async function installNoReplace(source, target) {
120
131
 
121
132
  async function secureKeyModes(privateKeyPath, publicKeyPath) {
122
133
  if (process.platform === "win32") return;
123
- await chmod(privateKeyPath, 0o600);
124
- await chmod(publicKeyPath, 0o644);
134
+ chmodRegularFileSync(privateKeyPath, 0o600, "SSH private key");
135
+ chmodRegularFileSync(publicKeyPath, 0o644, "SSH public key");
125
136
  }
126
137
 
127
- async function safeLstat(path) {
128
- try { return await lstat(path); } catch (error) {
138
+ function tryReadKeySnapshot(path, maxBytes, label) {
139
+ try { return readKeySnapshot(path, maxBytes, label); } catch (error) {
129
140
  if (error?.code === "ENOENT") return null;
130
141
  throw error;
131
142
  }
132
143
  }
133
144
 
145
+ function readKeySnapshot(path, maxBytes, label) {
146
+ return readBoundedRegularFileWithInfoSync(path, maxBytes, label);
147
+ }
148
+
149
+ function assertKeySnapshotCurrent(path, expected, maxBytes, label) {
150
+ const current = readKeySnapshot(path, maxBytes, label);
151
+ const sameIdentity = Number(current.info.dev) === Number(expected.info.dev)
152
+ && Number(current.info.ino) === Number(expected.info.ino)
153
+ && Number(current.info.size) === Number(expected.info.size)
154
+ && Number(current.info.mtimeMs) === Number(expected.info.mtimeMs);
155
+ const sameBytes = current.buffer.length === expected.buffer.length
156
+ && timingSafeEqual(current.buffer, expected.buffer);
157
+ if (!sameIdentity || !sameBytes) throw new Error(`${label} changed during inspection; retry`);
158
+ }
159
+
134
160
  function boundedComment(value) {
135
161
  const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
136
162
  if (!comment) return "machine-mcp";