machine-bridge-mcp 0.6.2 → 0.7.1

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.
@@ -86,7 +86,7 @@ export class LocalDaemon {
86
86
  protocol_version: MCP_PROTOCOL_VERSION,
87
87
  supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
88
88
  workspace: this.displayPath(this.workspace),
89
- workspace_name: basename(this.workspace),
89
+ workspace_name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
90
90
  policy: this.policy,
91
91
  policy_contract: {
92
92
  named_profile_is_canonical: this.policy.profile === "custom" || policyMatchesNamedProfile(this.policy),
@@ -99,6 +99,12 @@ export class LocalDaemon {
99
99
  operating_system_permissions_apply: true,
100
100
  host_policy_is_independent: true,
101
101
  },
102
+ tool_delivery: {
103
+ full_profile_scope: "local-daemon-and-relay-advertisement",
104
+ daemon_advertised_tool_count: this.tools().length + 1,
105
+ host_exposed_tools_known_to_server: false,
106
+ host_may_expose_subset: true,
107
+ },
102
108
  tools: ["server_info", ...this.tools()],
103
109
  observability: {
104
110
  per_tool_events: "debug-only",
@@ -144,7 +150,7 @@ export class LocalDaemon {
144
150
  if (this.closed) return;
145
151
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
146
152
  this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
147
- const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
153
+ const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret }, maxPayload: MAX_WS_MESSAGE_BYTES });
148
154
  this.ws = socket;
149
155
 
150
156
  socket.on("open", () => {
@@ -266,7 +272,7 @@ export class LocalDaemon {
266
272
  const durationMs = Date.now() - started;
267
273
  this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
268
274
  } catch (error) {
269
- const safeError = this.safeErrorMessage(error);
275
+ const safeError = this.safeErrorMessage(error, argumentsValue);
270
276
  this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
271
277
  const durationMs = Date.now() - started;
272
278
  this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
@@ -339,7 +345,7 @@ export class LocalDaemon {
339
345
  const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
340
346
  return {
341
347
  workspace: this.displayPath(this.workspace),
342
- workspaceName: basename(this.workspace),
348
+ workspaceName: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
343
349
  gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
344
350
  policy: this.policy,
345
351
  tools: ["server_info", ...this.tools()],
@@ -348,7 +354,7 @@ export class LocalDaemon {
348
354
  }
349
355
 
350
356
  listRoots() {
351
- const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
357
+ const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
352
358
  if (this.policy.unrestrictedPaths) {
353
359
  const home = process.env.HOME || process.env.USERPROFILE;
354
360
  if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
@@ -757,18 +763,22 @@ export class LocalDaemon {
757
763
  targetPath: target,
758
764
  comment: args.comment || `machine-mcp:${args.name}`,
759
765
  });
766
+ const exposePaths = args.expose_paths === true;
760
767
  return {
761
768
  name: key.name,
762
769
  created: key.created,
763
770
  registered: key.registered,
764
- private_key_path: this.displayPath(key.privateKeyPath),
765
- public_key_path: this.displayPath(key.publicKeyPath),
766
771
  fingerprint: key.fingerprint,
767
772
  key_type: key.keyType,
768
773
  private_mode: key.privateMode,
769
774
  public_mode: key.publicMode,
770
775
  private_key_content_exposed: key.privateKeyContentExposed,
771
776
  available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
777
+ paths_exposed: exposePaths,
778
+ ...(exposePaths ? {
779
+ private_key_path: resolve(key.privateKeyPath),
780
+ public_key_path: resolve(key.publicKeyPath),
781
+ } : {}),
772
782
  };
773
783
  }
774
784
 
@@ -831,7 +841,7 @@ export class LocalDaemon {
831
841
  timer.unref?.();
832
842
  const cleanup = () => {
833
843
  clearTimeout(timer);
834
- if (killTimer && !timedOut) clearTimeout(killTimer);
844
+ if (killTimer) clearTimeout(killTimer);
835
845
  this.activeProcesses.delete(child);
836
846
  if (context.callId) {
837
847
  const set = this.callProcesses.get(context.callId);
@@ -944,19 +954,25 @@ export class LocalDaemon {
944
954
 
945
955
  displayPath(fullPath) {
946
956
  const absolute = resolve(fullPath);
947
- if (this.policy.exposeAbsolutePaths || this.policy.unrestrictedPaths) return absolute;
948
- assertContainedPath(this.workspace, absolute);
957
+ if (this.policy.exposeAbsolutePaths) return absolute;
949
958
  const shown = relative(this.workspace, absolute);
950
- return shown ? shown.split(sep).join("/") : ".";
959
+ const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
960
+ if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
961
+ return `<external-path:${sha256(absolute).slice(0, 12)}>`;
951
962
  }
952
963
 
953
- safeErrorMessage(error) {
964
+ safeErrorMessage(error, toolArgs = {}) {
954
965
  let message = boundedErrorMessage(error);
955
966
  if (!this.policy.exposeAbsolutePaths) {
956
967
  for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
957
968
  for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
958
969
  const home = process.env.HOME || process.env.USERPROFILE;
959
970
  if (home) message = replacePathPrefix(message, resolve(home), "<home>");
971
+ for (const candidate of collectToolPathCandidates(error, toolArgs, this.workspaceInput)) {
972
+ const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(this.workspaceInput, candidate);
973
+ const replacement = this.displayPath(absolute);
974
+ for (const prefix of equivalentPathPrefixes(candidate, absolute)) message = replacePathPrefix(message, prefix, replacement);
975
+ }
960
976
  }
961
977
  return message;
962
978
  }
@@ -1202,6 +1218,27 @@ function isPlainRecord(value) {
1202
1218
  }
1203
1219
 
1204
1220
 
1221
+ function collectToolPathCandidates(error, toolArgs, workspace) {
1222
+ const candidates = new Set();
1223
+ for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
1224
+ const visit = (value, key = "", depth = 0) => {
1225
+ if (depth > 5 || value === null || value === undefined) return;
1226
+ if (typeof value === "string") {
1227
+ if (/(?:^|[_-])(?:path|cwd|workspace|root|directory|dir)(?:$|[_-])/i.test(key) && value && !value.includes("\0")) candidates.add(value);
1228
+ return;
1229
+ }
1230
+ if (Array.isArray(value)) {
1231
+ for (const item of value.slice(0, 64)) visit(item, key, depth + 1);
1232
+ return;
1233
+ }
1234
+ if (typeof value !== "object") return;
1235
+ for (const [childKey, child] of Object.entries(value).slice(0, 128)) visit(child, childKey, depth + 1);
1236
+ };
1237
+ visit(toolArgs);
1238
+ candidates.delete(workspace);
1239
+ return [...candidates].sort((left, right) => right.length - left.length);
1240
+ }
1241
+
1205
1242
  function equivalentPathPrefixes(...values) {
1206
1243
  const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
1207
1244
  for (const value of [...prefixes]) {
@@ -13,10 +13,13 @@ const MAX_OUTPUT_BYTES = 64 * 1024;
13
13
  const MAX_JOB_CAPTURE_BYTES = 256 * 1024;
14
14
  const MAX_RESULT_BYTES = 4 * 1024 * 1024;
15
15
  const MAX_STATUS_BYTES = 256 * 1024;
16
+ const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
16
17
 
17
18
  const options = parseArgs(process.argv.slice(2));
18
- const jobDir = resolve(options.jobDir || "");
19
- if (!jobDir) throw new Error("--job-dir is required");
19
+ const jobDirInput = typeof options.jobDir === "string" ? options.jobDir.trim() : "";
20
+ if (!jobDirInput) throw new Error("--job-dir is required");
21
+ const jobDir = resolve(jobDirInput);
22
+ if (!JOB_ID.test(basename(jobDir))) throw new Error("--job-dir must name a managed job directory");
20
23
  const recover = options.recover === true;
21
24
  const planFile = join(jobDir, "plan.json");
22
25
  const statusFile = join(jobDir, "status.json");
@@ -43,17 +46,19 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
43
46
  process.on(signal, () => requestCancellation());
44
47
  }
45
48
 
46
- writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600 });
49
+ const initial = readJson(statusFile, MAX_STATUS_BYTES);
50
+ assertLaunchState(initial);
51
+ writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
47
52
  if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
48
53
  try {
49
- await main();
54
+ const plan = readJson(planFile, 1024 * 1024);
55
+ assertPlanIntegrity(plan, initial);
56
+ await main(plan, initial);
50
57
  } catch (error) {
51
58
  recordFatalRunnerError(error);
52
59
  }
53
60
 
54
- async function main() {
55
- const plan = readJson(planFile, 1024 * 1024);
56
- const initial = readJson(statusFile, MAX_STATUS_BYTES);
61
+ async function main(plan, initial) {
57
62
  const status = {
58
63
  ...initial,
59
64
  runner_pid: process.pid,
@@ -137,6 +142,20 @@ async function main() {
137
142
  try { rmSync(cancelFile, { force: true }); } catch {}
138
143
  }
139
144
 
145
+ function assertLaunchState(status) {
146
+ if (!status || typeof status !== "object" || Array.isArray(status)) throw new Error("job status is unavailable or invalid");
147
+ const expected = recover ? "interrupted" : "queued";
148
+ if (status.status !== expected) throw new Error(`runner cannot start job in status: ${status.status}`);
149
+ if (status.approval === "pending-local-operator" || !status.approval) throw new Error("runner cannot start an unapproved managed job");
150
+ }
151
+
152
+ function assertPlanIntegrity(plan, status) {
153
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("job plan is unavailable or invalid");
154
+ const expected = String(status?.plan_sha256 || "");
155
+ const actual = createHash("sha256").update(JSON.stringify(plan)).digest("hex");
156
+ if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error("managed job plan integrity check failed");
157
+ }
158
+
140
159
  function recordFatalRunnerError(error) {
141
160
  rmSync(runtimeDir, { recursive: true, force: true });
142
161
  const now = new Date().toISOString();
@@ -234,11 +253,12 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
234
253
  let stderrTruncated = 0;
235
254
  let timedOut = false;
236
255
  let closed = false;
256
+ let killTimer = null;
237
257
  const timer = setTimeout(() => {
238
258
  timedOut = true;
239
259
  terminateProcessTree(child, "SIGTERM");
240
- const kill = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
241
- kill.unref?.();
260
+ killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
261
+ killTimer.unref?.();
242
262
  }, timeoutMs);
243
263
  timer.unref?.();
244
264
  const cancellationPoll = setInterval(() => {
@@ -279,6 +299,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
279
299
  if (closed) return;
280
300
  closed = true;
281
301
  clearTimeout(timer);
302
+ if (killTimer) clearTimeout(killTimer);
282
303
  clearInterval(cancellationPoll);
283
304
  activeChild = null;
284
305
  activeChildCancellationAware = false;
@@ -293,6 +314,7 @@ function materializeResources(resources) {
293
314
  mkdirSync(resourcesDir, { recursive: true, mode: 0o700 });
294
315
  chmodSync(resourcesDir, 0o700);
295
316
  const paths = {};
317
+ const sourcePaths = {};
296
318
  const bytes = {};
297
319
  const redactions = {};
298
320
  for (const [name, resource] of Object.entries(resources)) {
@@ -303,6 +325,10 @@ function materializeResources(resources) {
303
325
  const target = join(resourcesDir, name);
304
326
  writeFileSync(target, data, { mode: 0o600, flag: "wx" });
305
327
  paths[name] = target;
328
+ sourcePaths[name] = [...new Set([
329
+ ...resourcePathVariants(resource.path),
330
+ ...(Array.isArray(resource.pathAliases) ? resource.pathAliases.flatMap(resourcePathVariants) : []),
331
+ ])].sort((left, right) => right.length - left.length);
306
332
  bytes[name] = data;
307
333
  const patterns = [];
308
334
  try {
@@ -316,7 +342,7 @@ function materializeResources(resources) {
316
342
  }
317
343
  redactions[name] = [...new Set(patterns.filter((value) => value.length > 0))].sort((a, b) => b.length - a.length);
318
344
  }
319
- return { paths, bytes, redactions };
345
+ return { paths, sourcePaths, bytes, redactions };
320
346
  }
321
347
 
322
348
  function materializeTemporaryFiles(files) {
@@ -366,15 +392,46 @@ function substitute(value, plan, context) {
366
392
  });
367
393
  }
368
394
 
395
+ function resourcePathVariants(value) {
396
+ const canonical = resolve(value);
397
+ const variants = new Set(pathTextVariants(canonical));
398
+ if (process.platform === "darwin" && canonical.startsWith("/private/")) {
399
+ for (const variant of pathTextVariants(canonical.slice("/private".length))) variants.add(variant);
400
+ }
401
+ return [...variants].sort((left, right) => right.length - left.length);
402
+ }
403
+
404
+ function pathTextVariants(value) {
405
+ const path = String(value);
406
+ return [...new Set([path, path.replaceAll("\\", "/"), path.replaceAll("/", "\\")])];
407
+ }
408
+
409
+ function replacePathText(text, value, replacement) {
410
+ let output = text;
411
+ for (const variant of pathTextVariants(value)) {
412
+ if (!variant) continue;
413
+ if (process.platform === "win32") output = output.replace(new RegExp(escapeRegExp(variant), "gi"), replacement);
414
+ else output = output.split(variant).join(replacement);
415
+ }
416
+ return output;
417
+ }
418
+
419
+ function escapeRegExp(value) {
420
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
421
+ }
422
+
369
423
  function redactOutput(buffer, context) {
370
424
  let text = new TextDecoder("utf-8").decode(buffer);
371
425
  for (const [name, path] of Object.entries(context.paths)) {
372
- text = text.split(path).join(`<resource:${name}>`);
426
+ text = replacePathText(text, path, `<resource:${name}>`);
427
+ }
428
+ for (const [name, paths] of Object.entries(context.sourcePaths || {})) {
429
+ for (const path of paths) text = replacePathText(text, path, `<resource-source:${name}>`);
373
430
  }
374
431
  for (const [name, path] of Object.entries(context.temporaryPaths)) {
375
- text = text.split(path).join(`<temp:${name}>`);
432
+ text = replacePathText(text, path, `<temp:${name}>`);
376
433
  }
377
- text = text.split(runtimeDir).join("<job-runtime>");
434
+ text = replacePathText(text, runtimeDir, "<job-runtime>");
378
435
  for (const [name, patterns] of Object.entries(context.redactions)) {
379
436
  for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
380
437
  }
package/src/local/log.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import os from "node:os";
2
3
 
3
4
  const COLORS = {
4
5
  reset: "\x1b[0m",
@@ -15,8 +16,16 @@ const MAX_LOG_ARRAY_ITEMS = 32;
15
16
  const MAX_LOG_OBJECT_KEYS = 48;
16
17
  const LEVEL_RANK = Object.freeze({ debug: 10, info: 20, success: 20, warn: 30, error: 40 });
17
18
  const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
19
+ const LOCAL_PATH_KEY = /(path|paths|cwd|workspace|directory|(?:^|[_-])dir(?:$|[_-])|root|home)/i;
18
20
  const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
19
21
  const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
22
+ const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
23
+ const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
24
+ const GITHUB_TOKEN = /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g;
25
+ const API_SECRET = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g;
26
+ const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g;
27
+ const HOME_PATHS = [...new Set([process.env.HOME, process.env.USERPROFILE, safeHomeDirectory()].filter(value => typeof value === "string" && value.length > 1))]
28
+ .sort((left, right) => right.length - left.length);
20
29
 
21
30
  export function createLogger(options = {}) {
22
31
  const quiet = Boolean(options.quiet);
@@ -86,8 +95,9 @@ export function formatFields(fields) {
86
95
  }
87
96
  }
88
97
 
89
- export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
98
+ function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
90
99
  if (SENSITIVE_KEY.test(key)) return "<redacted>";
100
+ if (LOCAL_PATH_KEY.test(key)) return "<local-path>";
91
101
  if (value instanceof Error) return sanitizeLogText(value.message || value.name);
92
102
  if (typeof value === "string") return sanitizeLogText(value);
93
103
  if (typeof value === "bigint") return value.toString();
@@ -106,9 +116,19 @@ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth =
106
116
  export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
107
117
  let raw;
108
118
  try { raw = String(value ?? ""); } catch { raw = "<unprintable>"; }
109
- const sanitized = raw
119
+ let sanitized = raw
110
120
  .replace(SECRET_VALUE, "<redacted-secret>")
111
121
  .replace(BEARER_VALUE, "Bearer <redacted>")
122
+ .replace(AWS_ACCESS_KEY, "<redacted-cloud-key>")
123
+ .replace(GITHUB_TOKEN, "<redacted-access-token>")
124
+ .replace(API_SECRET, "<redacted-api-secret>")
125
+ .replace(PRIVATE_KEY_HEADER, "<redacted-private-key-header>")
126
+ .replace(EMAIL_VALUE, "<redacted-email>");
127
+ for (const home of HOME_PATHS) sanitized = sanitized.split(home).join("<home>");
128
+ sanitized = sanitized
129
+ .replace(/\/(?:Users|home)\/[^/\s"'<>]+(?=\/|$)/g, "<home>")
130
+ .replace(/\b[A-Za-z]:\\Users\\[^\\\s"'<>]+(?=\\|$)/g, "<home>")
131
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, "")
112
132
  .replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
113
133
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
114
134
  if (!Number.isFinite(Number(maxChars)) || Number(maxChars) <= 0) return "";
@@ -116,6 +136,10 @@ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
116
136
  return sanitized.length > limit ? `${sanitized.slice(0, limit - 1)}…` : sanitized;
117
137
  }
118
138
 
139
+ function safeHomeDirectory() {
140
+ try { return os.homedir(); } catch { return ""; }
141
+ }
142
+
119
143
  function shouldUseColor(options) {
120
144
  if (options.color === false || process.env.NO_COLOR) return false;
121
145
  if (options.color === true) return true;