machine-bridge-mcp 0.18.1 → 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 (46) hide show
  1. package/CHANGELOG.md +29 -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.mjs +25 -35
  35. package/src/local/shell.mjs +26 -7
  36. package/src/local/ssh-key.mjs +71 -45
  37. package/src/local/state.mjs +46 -56
  38. package/src/local/worker-secret-file.mjs +130 -0
  39. package/src/local/workspace-file-service.mjs +10 -2
  40. package/src/shared/server-metadata.json +2 -3
  41. package/src/worker/http.ts +7 -8
  42. package/src/worker/index.ts +31 -41
  43. package/src/worker/mcp-session.ts +72 -0
  44. package/src/worker/oauth-state.ts +1 -1
  45. package/src/worker/pending-calls.ts +36 -5
  46. 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
  }
@@ -1,10 +1,10 @@
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
8
  import { waitForInactiveStatus } from "./service-convergence.mjs";
9
9
 
10
10
  const LABEL = "dev.machine-bridge-mcp.daemon";
@@ -13,7 +13,11 @@ const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
13
13
  const AUTOSTART_LOG_SCHEMA_VERSION = 3;
14
14
 
15
15
  function serviceRun(command, args) {
16
- return run(command, args, {
16
+ return runServiceCommand(command, args);
17
+ }
18
+
19
+ export function runServiceCommand(command, args, execute = runExecutable) {
20
+ return execute(command, args, {
17
21
  capture: true,
18
22
  allowFailure: true,
19
23
  maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
@@ -34,9 +38,9 @@ export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
34
38
  }
35
39
 
36
40
  export async function autostartStatus({ logger = console } = {}) {
37
- if (process.platform === "darwin") return statusLaunchd(logger);
38
- if (process.platform === "win32") return statusWindowsTask(logger);
39
- return statusSystemd(logger);
41
+ if (process.platform === "darwin") return statusLaunchd();
42
+ if (process.platform === "win32") return statusWindowsTask();
43
+ return statusSystemd();
40
44
  }
41
45
 
42
46
  export async function startAutostart({ logger = console } = {}) {
@@ -82,21 +86,18 @@ export function trimAutostartLogs(stateRoot, options = {}) {
82
86
  const file = path.join(logs, name);
83
87
  let fd;
84
88
  try {
85
- if (!existsSync(file)) continue;
86
- const before = lstatSync(file);
87
- if (before.isSymbolicLink() || !before.isFile()) continue;
88
- const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
89
- fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
90
- const info = fstatSync(fd);
91
- 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;
92
94
  if (reset) {
93
95
  ftruncateSync(fd, 0);
94
- } else if (info.size > maxBytes) {
95
- const tail = readLogTail(fd, info.size, keepBytes);
96
+ } else if (opened.info.size > maxBytes) {
97
+ const tail = readLogTail(fd, opened.info.size, keepBytes);
96
98
  ftruncateSync(fd, 0);
97
99
  if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
98
100
  }
99
- try { chmodSync(file, 0o600); } catch {}
100
101
  } catch {
101
102
  // Log maintenance is best effort and must not stop daemon startup.
102
103
  } finally {
@@ -213,28 +214,17 @@ export function daemonArgs(spec) {
213
214
  }
214
215
 
215
216
  function ensurePrivateLogFile(file) {
216
- if (existsSync(file)) {
217
- const info = lstatSync(file);
218
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart log path must be a regular non-symbolic-link file");
219
- }
220
- const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
221
- const fd = openSync(file, Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | noFollow, 0o600);
222
- try {
223
- if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
224
- } finally {
225
- closeSync(fd);
226
- }
227
- 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);
228
223
  }
229
224
 
230
225
  function writePrivateServiceFile(file, content) {
231
226
  mkdirSync(path.dirname(file), { recursive: true });
232
- if (existsSync(file)) {
233
- const info = lstatSync(file);
234
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
235
- }
236
227
  replaceFileAtomicallySync(file, content, { mode: 0o600 });
237
- ownerOnlyFile(file);
238
228
  }
239
229
 
240
230
  function launchdPlistPath() {
@@ -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";
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync, lstatSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -7,6 +7,7 @@ import serverMetadata from "../shared/server-metadata.json" with { type: "json"
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
8
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
9
9
  import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
10
+ import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
10
11
 
11
12
  export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
12
13
  export const appName = String(serverMetadata.name);
@@ -401,28 +402,11 @@ function boundedPositiveInteger(value, fallback) {
401
402
  }
402
403
 
403
404
  function readBoundedUtf8(filePath, maxBytes, label) {
404
- const pathInfo = lstatSync(filePath);
405
- if (pathInfo.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
406
- const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
407
- const fd = openSync(filePath, Number(fsConstants.O_RDONLY) | noFollow);
405
+ const buffer = readBoundedRegularFileSync(filePath, maxBytes, label);
408
406
  try {
409
- const info = fstatSync(fd);
410
- if (!info.isFile()) throw new Error(`${label} is not a regular file`);
411
- if (info.size > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
412
- const buffer = Buffer.alloc(info.size);
413
- let offset = 0;
414
- while (offset < buffer.length) {
415
- const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
416
- if (count === 0) break;
417
- offset += count;
418
- }
419
- try {
420
- return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
421
- } catch {
422
- throw new Error(`${label} is not valid UTF-8`);
423
- }
424
- } finally {
425
- closeSync(fd);
407
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
408
+ } catch {
409
+ throw new Error(`${label} is not valid UTF-8`);
426
410
  }
427
411
  }
428
412
 
@@ -511,33 +495,48 @@ function looksLikeSourceTree(root) {
511
495
  }
512
496
 
513
497
  function stateRootMatchesRecordedWorkspace(root) {
514
- const config = path.join(root, "config.json");
515
- if (existsSync(config)) {
516
- try {
517
- const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
518
- if (typeof value?.selectedWorkspace === "string" && sameWorkspaceIdentity(value.selectedWorkspace, root)) return true;
519
- } catch {}
520
- }
498
+ const config = readOptionalRemovalJson(path.join(root, "config.json"), "state-root config");
499
+ if (typeof config?.selectedWorkspace === "string" && sameWorkspaceIdentity(config.selectedWorkspace, root)) return true;
500
+
521
501
  const profiles = path.join(root, "profiles");
522
- if (!existsSync(profiles)) return false;
502
+ let entries;
523
503
  try {
524
- for (const entry of readdirSync(profiles, { withFileTypes: true })) {
525
- if (!entry.isDirectory()) continue;
526
- const profileDir = path.join(profiles, entry.name);
527
- const stateFile = path.join(profileDir, "state.json");
528
- if (existsSync(stateFile)) {
529
- try {
530
- const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
531
- if (typeof value?.workspace?.path === "string" && sameWorkspaceIdentity(value.workspace.path, root)) return true;
532
- } catch {}
533
- }
534
- const owner = readDaemonLockOwner(path.join(profileDir, "daemon.lock"));
535
- if (typeof owner?.workspace === "string" && sameWorkspaceIdentity(owner.workspace, root)) return true;
536
- }
537
- } catch {}
504
+ entries = readdirSync(profiles, { withFileTypes: true });
505
+ } catch (error) {
506
+ if (error?.code === "ENOENT") return false;
507
+ throw new Error("could not inspect state-root profiles before removal", { cause: error });
508
+ }
509
+ for (const entry of entries) {
510
+ if (!entry.isDirectory()) continue;
511
+ const profileDir = path.join(profiles, entry.name);
512
+ const state = readOptionalRemovalJson(path.join(profileDir, "state.json"), "profile state");
513
+ if (typeof state?.workspace?.path === "string" && sameWorkspaceIdentity(state.workspace.path, root)) return true;
514
+ const owner = readOptionalRemovalJson(path.join(profileDir, "daemon.lock"), "daemon lock");
515
+ if (typeof owner?.workspace === "string" && sameWorkspaceIdentity(owner.workspace, root)) return true;
516
+ }
538
517
  return false;
539
518
  }
540
519
 
520
+ function readOptionalRemovalJson(file, label) {
521
+ let text;
522
+ try {
523
+ text = readBoundedUtf8(file, MAX_STATE_JSON_BYTES, label);
524
+ } catch (error) {
525
+ if (error?.code === "ENOENT" || error?.cause?.code === "ENOENT") return null;
526
+ throw new Error(`${label} could not be verified before state removal`, { cause: error });
527
+ }
528
+ let value;
529
+ try {
530
+ value = JSON.parse(text);
531
+ } catch (error) {
532
+ throw new Error(`${label} is not valid JSON; refusing state removal`, { cause: error });
533
+ }
534
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
535
+ throw new Error(`${label} must contain a JSON object; refusing state removal`);
536
+ }
537
+ return value;
538
+ }
539
+
541
540
  function atomicWriteJson(filePath, value) {
542
541
  const dir = path.dirname(filePath);
543
542
  ensureOwnerOnlyDir(dir);
@@ -595,21 +594,12 @@ function randomToken(prefix) {
595
594
  return `${prefix}_${randomBytes(32).toString("base64url")}`;
596
595
  }
597
596
 
598
- function sha256(value) {
599
- return createHash("sha256").update(String(value)).digest("hex");
600
- }
601
-
602
-
603
- export function ensureOwnerOnlyDir(dir) {
604
- mkdirSync(dir, { recursive: true, mode: 0o700 });
605
- try { chmodSync(dir, 0o700); } catch {}
597
+ export function ensureOwnerOnlyDir(dir, options = {}) {
598
+ return ensureOwnerOnlyDirectorySync(dir, options);
606
599
  }
607
600
 
608
601
  export function ownerOnlyFile(filePath) {
609
- const info = lstatSync(filePath);
610
- if (info.isSymbolicLink()) throw new Error(`owner-only file must not be a symbolic link: ${filePath}`);
611
- if (!info.isFile()) throw new Error(`owner-only path is not a regular file: ${filePath}`);
612
- try { chmodSync(filePath, 0o600); } catch {}
602
+ chmodRegularFileSync(filePath, 0o600, "owner-only path");
613
603
  }
614
604
 
615
605
  export function redactState(state) {