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.
- package/CHANGELOG.md +83 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +32 -12
- package/SECURITY.md +20 -10
- package/docs/ARCHITECTURE.md +16 -8
- package/docs/CLIENTS.md +11 -1
- package/docs/LOGGING.md +13 -18
- package/docs/MANAGED_JOBS.md +20 -11
- package/docs/OPERATIONS.md +31 -10
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +22 -2
- package/package.json +39 -10
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +113 -15
- package/src/local/daemon.mjs +114 -15
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +73 -15
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +165 -75
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +127 -0
- package/src/local/state.mjs +12 -9
- package/src/local/stdio.mjs +78 -20
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +4 -1
- package/src/shared/tool-catalog.json +37 -0
- package/src/worker/index.ts +41 -18
- package/wrangler.jsonc +1 -1
|
@@ -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
|
+
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
18
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
432
|
+
text = replacePathText(text, path, `<temp:${name}>`);
|
|
375
433
|
}
|
|
376
|
-
text = text
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|