machine-bridge-mcp 0.6.0 → 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.
@@ -0,0 +1,206 @@
1
+ import { chmod, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import process from "node:process";
5
+ import { LocalDaemon } from "./daemon.mjs";
6
+ import { generateSshKeyPair } from "./ssh-key.mjs";
7
+ import { run } from "./shell.mjs";
8
+ import { allToolNames, assertCanonicalFullPolicy, policyProfile } from "./tools.mjs";
9
+
10
+ const TERMINAL_JOB_STATES = new Set([
11
+ "succeeded", "failed", "cancelled", "runner_failed", "runner_launch_failed",
12
+ "recovery_failed", "recovery_exhausted", "succeeded_cleanup_failed",
13
+ "failed_cleanup_failed", "cancelled_cleanup_failed",
14
+ ]);
15
+
16
+ export async function runFullAccessTest({ workspace, policy = policyProfile("full", "explicit") } = {}) {
17
+ const canonicalPolicy = assertCanonicalFullPolicy(policy);
18
+ const root = await mkdtemp(join(tmpdir(), "machine-mcp-full-test-"));
19
+ const jobRoot = join(root, "jobs");
20
+ const outsideDir = join(root, "outside-workspace");
21
+ const keyPath = join(root, "ssh", "operator-ed25519");
22
+ const authorizedKeysPath = join(root, "authorized_keys");
23
+ const mainMarker = join(root, "managed-main.txt");
24
+ const cleanupMarker = join(root, "managed-cleanup.txt");
25
+ const checks = [];
26
+ const sentinelKey = `MBM_FULL_TEST_${Date.now()}`;
27
+ const previousSentinel = process.env[sentinelKey];
28
+ process.env[sentinelKey] = "visible";
29
+ let runtime;
30
+
31
+ try {
32
+ runtime = new LocalDaemon({
33
+ workspace: resolve(workspace),
34
+ policy: canonicalPolicy,
35
+ jobRoot,
36
+ resources: {},
37
+ logger: silentLogger(),
38
+ recoverJobs: false,
39
+ });
40
+
41
+ const toolNames = runtime.tools();
42
+ checks.push(check("full-policy-invariant", toolNames.length === allToolNames().length - 1, {
43
+ profile: canonicalPolicy.profile,
44
+ exposed_tools: toolNames.length + 1,
45
+ catalog_tools: allToolNames().length,
46
+ }));
47
+
48
+ const outsideFile = join(outsideDir, "outside.txt");
49
+ const written = await runtime.writeFile({ path: outsideFile, content: "full-outside-write\n", create_only: true });
50
+ const read = await runtime.readFile(outsideFile, 1024);
51
+ const canonicalOutsideFile = await realpath(outsideFile);
52
+ checks.push(check("unrestricted-filesystem", written.ok === true && read.content === "full-outside-write\n", {
53
+ absolute_path_returned: await equivalentPath(read.path, canonicalOutsideFile),
54
+ }));
55
+
56
+ const direct = await runtime.runDirectProcess({
57
+ argv: [process.execPath, "-e", "process.stdout.write(process.cwd())"],
58
+ cwd: outsideDir,
59
+ timeout_seconds: 10,
60
+ });
61
+ checks.push(check("direct-process-outside-workspace", direct.code === 0 && await equivalentPath(direct.stdout.trim(), outsideDir)));
62
+
63
+ const inherited = await runtime.runDirectProcess({
64
+ argv: [process.execPath, "-e", `process.stdout.write(process.env[${JSON.stringify(sentinelKey)}] || '')`],
65
+ timeout_seconds: 10,
66
+ });
67
+ checks.push(check("full-parent-environment", inherited.code === 0 && inherited.stdout === "visible"));
68
+
69
+ const shellCommand = process.platform === "win32" ? "[Console]::Out.Write('full-shell')" : "printf full-shell";
70
+ const shell = await runtime.execCommand(shellCommand, 10);
71
+ checks.push(check("shell-execution", shell.code === 0 && shell.stdout.trim() === "full-shell"));
72
+
73
+ const key = await generateSshKeyPair({
74
+ privateKeyPath: keyPath,
75
+ type: "ed25519",
76
+ comment: "machine-mcp-full-test",
77
+ });
78
+ const publicLine = (await readFile(key.publicKeyPath, "utf8")).trim();
79
+ await runtime.writeFile({ path: authorizedKeysPath, content: `${publicLine}\n`, create_only: true });
80
+ if (process.platform !== "win32") await chmod(authorizedKeysPath, 0o600);
81
+ const authorized = await readFile(authorizedKeysPath, "utf8");
82
+ checks.push(check("ssh-key-generation", key.created && key.publicKeyType === "ssh-ed25519", {
83
+ private_mode: key.privateMode,
84
+ public_mode: key.publicMode,
85
+ fingerprint_available: Boolean(key.fingerprint),
86
+ private_key_content_exposed: false,
87
+ }));
88
+ checks.push(check("authorized-keys-sandbox-write", authorized === `${publicLine}\n`, {
89
+ target_is_temporary_sandbox: true,
90
+ }));
91
+
92
+ const nullConfig = process.platform === "win32" ? "NUL" : "/dev/null";
93
+ const sshConfig = await run("ssh", ["-F", nullConfig, "-G", "localhost"], {
94
+ capture: true,
95
+ allowFailure: true,
96
+ timeoutMs: 15_000,
97
+ maxOutputBytes: 256 * 1024,
98
+ });
99
+ checks.push(check("ssh-client", sshConfig.code === 0));
100
+
101
+ const gcloud = await run("gcloud", ["--version"], {
102
+ capture: true,
103
+ allowFailure: true,
104
+ timeoutMs: 30_000,
105
+ maxOutputBytes: 64 * 1024,
106
+ });
107
+ const osLoginHelp = gcloud.code === 0
108
+ ? await run("gcloud", ["help", "compute", "os-login", "ssh-keys", "add"], {
109
+ capture: true,
110
+ allowFailure: true,
111
+ timeoutMs: 30_000,
112
+ maxOutputBytes: 128 * 1024,
113
+ })
114
+ : { code: 127 };
115
+ checks.push(check("google-cloud-cli", gcloud.code === 0, {
116
+ os_login_key_command_available: osLoginHelp.code === 0,
117
+ external_changes_made: false,
118
+ }));
119
+
120
+ const sudo = process.platform === "win32"
121
+ ? { code: 0, skipped: true }
122
+ : await run("sudo", ["-n", "true"], {
123
+ capture: true,
124
+ allowFailure: true,
125
+ timeoutMs: 10_000,
126
+ maxOutputBytes: 16 * 1024,
127
+ });
128
+ checks.push({
129
+ name: "noninteractive-sudo-probe",
130
+ ok: true,
131
+ available: process.platform === "win32" ? null : sudo.code === 0,
132
+ password_or_policy_required: process.platform === "win32" ? null : sudo.code !== 0,
133
+ skipped: process.platform === "win32",
134
+ state_changed: false,
135
+ });
136
+
137
+ const accepted = runtime.managedJobManager.start({
138
+ name: "full access lifecycle test",
139
+ temporary_files: [{ name: "main.js", content: "require('node:fs').writeFileSync(process.argv[2],'main')" }],
140
+ steps: [{ argv: [process.execPath, "{{temp:main.js}}", mainMarker], timeout_seconds: 10 }],
141
+ finally_steps: [{ argv: [process.execPath, "-e", "require('node:fs').writeFileSync(process.argv[1],'cleanup')", cleanupMarker], timeout_seconds: 10 }],
142
+ });
143
+ const job = await waitForJob(runtime.managedJobManager, accepted.job_id, 15_000);
144
+ checks.push(check("detached-managed-job", job.status === "succeeded"
145
+ && await fileEquals(mainMarker, "main")
146
+ && await fileEquals(cleanupMarker, "cleanup"), {
147
+ status: job.status,
148
+ error_class: job.error_class ?? job.result?.error_class ?? null,
149
+ cleanup_error_class: job.result?.cleanup_error_class ?? null,
150
+ cleanup_attempted: true,
151
+ }));
152
+
153
+ const coreChecks = checks.filter((item) => !["google-cloud-cli", "noninteractive-sudo-probe"].includes(item.name));
154
+ const operatorChecks = checks.filter((item) => ["ssh-key-generation", "authorized-keys-sandbox-write", "ssh-client", "google-cloud-cli"].includes(item.name));
155
+ return {
156
+ ok: coreChecks.every((item) => item.ok === true),
157
+ operator_workflow_ready: operatorChecks.every((item) => item.ok === true),
158
+ machine: {
159
+ platform: process.platform,
160
+ architecture: process.arch,
161
+ node: process.version,
162
+ },
163
+ policy: canonicalPolicy,
164
+ checks,
165
+ guarantees: {
166
+ machine_bridge_internal_policy_denials_under_full: false,
167
+ host_or_connector_policy_overridden: false,
168
+ operating_system_policy_overridden: false,
169
+ external_cloud_or_remote_state_changed: false,
170
+ },
171
+ };
172
+ } finally {
173
+ runtime?.stop();
174
+ if (previousSentinel === undefined) delete process.env[sentinelKey];
175
+ else process.env[sentinelKey] = previousSentinel;
176
+ await rm(root, { recursive: true, force: true });
177
+ }
178
+ }
179
+
180
+ async function waitForJob(manager, jobId, timeoutMs) {
181
+ const deadline = Date.now() + timeoutMs;
182
+ while (Date.now() < deadline) {
183
+ const value = manager.read({ job_id: jobId });
184
+ if (TERMINAL_JOB_STATES.has(value.status)) return value;
185
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
186
+ }
187
+ throw new Error("full access managed-job test timed out");
188
+ }
189
+
190
+ async function equivalentPath(left, right) {
191
+ try { return await realpath(resolve(left)) === await realpath(resolve(right)); } catch { return false; }
192
+ }
193
+
194
+ async function fileEquals(path, expected) {
195
+ try { return await readFile(path, "utf8") === expected; } catch { return false; }
196
+ }
197
+
198
+ function check(name, ok, detail = {}) {
199
+ return { name, ok: Boolean(ok), ...detail };
200
+ }
201
+
202
+ function silentLogger() {
203
+ return {
204
+ info() {}, success() {}, warn() {}, error() {}, debug() {},
205
+ };
206
+ }
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { executionEnv } from "./shell.mjs";
6
6
  import { terminateProcessTree } from "./process-sessions.mjs";
7
+ import { replaceFileSync } from "./atomic-fs.mjs";
7
8
 
8
9
  const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
9
10
  const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
@@ -12,10 +13,13 @@ const MAX_OUTPUT_BYTES = 64 * 1024;
12
13
  const MAX_JOB_CAPTURE_BYTES = 256 * 1024;
13
14
  const MAX_RESULT_BYTES = 4 * 1024 * 1024;
14
15
  const MAX_STATUS_BYTES = 256 * 1024;
16
+ const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
15
17
 
16
18
  const options = parseArgs(process.argv.slice(2));
17
- const jobDir = resolve(options.jobDir || "");
18
- 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");
19
23
  const recover = options.recover === true;
20
24
  const planFile = join(jobDir, "plan.json");
21
25
  const statusFile = join(jobDir, "status.json");
@@ -42,17 +46,19 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
42
46
  process.on(signal, () => requestCancellation());
43
47
  }
44
48
 
45
- 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" });
46
52
  if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
47
53
  try {
48
- await main();
54
+ const plan = readJson(planFile, 1024 * 1024);
55
+ assertPlanIntegrity(plan, initial);
56
+ await main(plan, initial);
49
57
  } catch (error) {
50
58
  recordFatalRunnerError(error);
51
59
  }
52
60
 
53
- async function main() {
54
- const plan = readJson(planFile, 1024 * 1024);
55
- const initial = readJson(statusFile, MAX_STATUS_BYTES);
61
+ async function main(plan, initial) {
56
62
  const status = {
57
63
  ...initial,
58
64
  runner_pid: process.pid,
@@ -136,6 +142,20 @@ async function main() {
136
142
  try { rmSync(cancelFile, { force: true }); } catch {}
137
143
  }
138
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
+
139
159
  function recordFatalRunnerError(error) {
140
160
  rmSync(runtimeDir, { recursive: true, force: true });
141
161
  const now = new Date().toISOString();
@@ -233,11 +253,12 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
233
253
  let stderrTruncated = 0;
234
254
  let timedOut = false;
235
255
  let closed = false;
256
+ let killTimer = null;
236
257
  const timer = setTimeout(() => {
237
258
  timedOut = true;
238
259
  terminateProcessTree(child, "SIGTERM");
239
- const kill = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
240
- kill.unref?.();
260
+ killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
261
+ killTimer.unref?.();
241
262
  }, timeoutMs);
242
263
  timer.unref?.();
243
264
  const cancellationPoll = setInterval(() => {
@@ -278,6 +299,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
278
299
  if (closed) return;
279
300
  closed = true;
280
301
  clearTimeout(timer);
302
+ if (killTimer) clearTimeout(killTimer);
281
303
  clearInterval(cancellationPoll);
282
304
  activeChild = null;
283
305
  activeChildCancellationAware = false;
@@ -292,6 +314,7 @@ function materializeResources(resources) {
292
314
  mkdirSync(resourcesDir, { recursive: true, mode: 0o700 });
293
315
  chmodSync(resourcesDir, 0o700);
294
316
  const paths = {};
317
+ const sourcePaths = {};
295
318
  const bytes = {};
296
319
  const redactions = {};
297
320
  for (const [name, resource] of Object.entries(resources)) {
@@ -302,6 +325,10 @@ function materializeResources(resources) {
302
325
  const target = join(resourcesDir, name);
303
326
  writeFileSync(target, data, { mode: 0o600, flag: "wx" });
304
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);
305
332
  bytes[name] = data;
306
333
  const patterns = [];
307
334
  try {
@@ -315,7 +342,7 @@ function materializeResources(resources) {
315
342
  }
316
343
  redactions[name] = [...new Set(patterns.filter((value) => value.length > 0))].sort((a, b) => b.length - a.length);
317
344
  }
318
- return { paths, bytes, redactions };
345
+ return { paths, sourcePaths, bytes, redactions };
319
346
  }
320
347
 
321
348
  function materializeTemporaryFiles(files) {
@@ -365,15 +392,46 @@ function substitute(value, plan, context) {
365
392
  });
366
393
  }
367
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
+
368
423
  function redactOutput(buffer, context) {
369
424
  let text = new TextDecoder("utf-8").decode(buffer);
370
425
  for (const [name, path] of Object.entries(context.paths)) {
371
- 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}>`);
372
430
  }
373
431
  for (const [name, path] of Object.entries(context.temporaryPaths)) {
374
- text = text.split(path).join(`<temp:${name}>`);
432
+ text = replacePathText(text, path, `<temp:${name}>`);
375
433
  }
376
- text = text.split(runtimeDir).join("<job-runtime>");
434
+ text = replacePathText(text, runtimeDir, "<job-runtime>");
377
435
  for (const [name, patterns] of Object.entries(context.redactions)) {
378
436
  for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
379
437
  }
@@ -419,7 +477,7 @@ function writeJson(file, value, maxBytes) {
419
477
  if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
420
478
  const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
421
479
  writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
422
- renameSync(temp, file);
480
+ replaceFileSync(temp, file);
423
481
  chmodSync(file, 0o600);
424
482
  }
425
483
 
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;