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.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- package/SECURITY.md +8 -6
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +0 -1
- package/docs/AGENT_CONTEXT.md +2 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUDIT.md +23 -0
- package/docs/ENGINEERING.md +3 -1
- package/docs/GETTING_STARTED.md +3 -0
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/OPERATIONS.md +21 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +6 -2
- package/package.json +6 -3
- package/scripts/privacy-check.mjs +13 -13
- package/scripts/sarif-security-gate.mjs +146 -0
- package/src/local/account-access.mjs +0 -1
- package/src/local/atomic-fs.mjs +1 -1
- package/src/local/browser-bridge.mjs +2 -2
- package/src/local/browser-extension-protocol.mjs +1 -1
- package/src/local/browser-pairing-store.mjs +3 -4
- package/src/local/cli.mjs +21 -55
- package/src/local/default-instructions.mjs +4 -3
- package/src/local/errors.mjs +1 -5
- package/src/local/exclusive-file.mjs +9 -3
- package/src/local/full-access-test.mjs +5 -5
- package/src/local/job-runner.mjs +1 -1
- package/src/local/managed-jobs.mjs +27 -51
- package/src/local/relay-connection.mjs +7 -0
- package/src/local/runtime.mjs +20 -6
- package/src/local/secure-file.mjs +89 -10
- package/src/local/service.mjs +25 -35
- package/src/local/shell.mjs +26 -7
- package/src/local/ssh-key.mjs +71 -45
- package/src/local/state.mjs +46 -56
- package/src/local/worker-secret-file.mjs +130 -0
- package/src/local/workspace-file-service.mjs +10 -2
- package/src/shared/server-metadata.json +2 -3
- package/src/worker/http.ts +7 -8
- package/src/worker/index.ts +31 -41
- package/src/worker/mcp-session.ts +72 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/pending-calls.ts +36 -5
- package/src/worker/tool-timeout.ts +18 -0
package/src/local/runtime.mjs
CHANGED
|
@@ -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
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { RelayConnection } from "./relay-connection.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { ProcessSessionManager } from "./process-sessions.mjs";
|
|
8
8
|
export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
|
|
9
|
-
import { allToolNames,
|
|
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.
|
|
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
|
-
|
|
386
|
+
response = { type: "tool_result", id: envelope.id, ok: true, result };
|
|
381
387
|
} catch (error) {
|
|
382
|
-
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/local/service.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
import { ensureOwnerOnlyDir, expandHome
|
|
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
|
|
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(
|
|
38
|
-
if (process.platform === "win32") return statusWindowsTask(
|
|
39
|
-
return statusSystemd(
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
fd =
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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() {
|
package/src/local/shell.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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 || `${
|
|
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
|
-
|
|
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
|
|
125
|
+
return runExecutable(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
export function workspaceShellCommand(command) {
|
package/src/local/ssh-key.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { constants as fsConstants } from "node:fs";
|
|
2
|
-
import { chmod, copyFile, link,
|
|
3
|
-
import { basename, dirname, resolve } from "node:path";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
-
import {
|
|
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
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
if (!
|
|
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
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
124
|
-
|
|
134
|
+
chmodRegularFileSync(privateKeyPath, 0o600, "SSH private key");
|
|
135
|
+
chmodRegularFileSync(publicKeyPath, 0o644, "SSH public key");
|
|
125
136
|
}
|
|
126
137
|
|
|
127
|
-
|
|
128
|
-
try { return
|
|
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";
|
package/src/local/state.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 (
|
|
516
|
-
|
|
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
|
-
|
|
502
|
+
let entries;
|
|
523
503
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
599
|
-
return
|
|
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
|
-
|
|
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) {
|