396 lines
13 KiB
JavaScript
396 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
access,
|
|
chmod,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, join, isAbsolute } from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import { createReadStream } from "node:fs";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { z } from "zod";
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
const { values } = parseArgs({
|
|
options: {
|
|
"runtime-root": { type: "string" },
|
|
revision: { type: "string" },
|
|
path: { type: "string" },
|
|
providers: { type: "string" },
|
|
output: { type: "string" },
|
|
"disabled-analyzers": {
|
|
type: "string",
|
|
default: "Decompiler Switch Analysis",
|
|
},
|
|
"timeout-ms": { type: "string", default: "900000" },
|
|
},
|
|
});
|
|
for (const key of ["runtime-root", "path", "providers", "output"])
|
|
assert.ok(values[key] && isAbsolute(values[key]), "Supply --" + key + " ABS");
|
|
assert.match(values.revision ?? "", /^[a-f0-9]{40}$/u);
|
|
assert.equal(process.platform, "linux");
|
|
assert.notEqual(
|
|
process.getuid(),
|
|
0,
|
|
"Permission fault injection requires a non-root user",
|
|
);
|
|
const timeout = Number(values["timeout-ms"]);
|
|
assert.ok(Number.isSafeInteger(timeout) && timeout > 0);
|
|
const lab = values.output,
|
|
runtime = values["runtime-root"];
|
|
const { ghidraDisabledAnalyzersSchema } = await import(
|
|
pathToFileURL(runtime + "/dist/config/ghidraDisabledAnalyzers.js")
|
|
);
|
|
assert.ok(
|
|
ghidraDisabledAnalyzersSchema.parse(values["disabled-analyzers"]) !==
|
|
undefined,
|
|
"Select at least one analyzer to disable",
|
|
);
|
|
await mkdir(lab, { mode: 0o700 });
|
|
const providerBytes = await readFile(values.providers);
|
|
const providers = JSON.parse(providerBytes.toString("utf8"));
|
|
assert.ok(
|
|
providers !== null &&
|
|
!Array.isArray(providers) &&
|
|
typeof providers === "object",
|
|
);
|
|
assert.ok(Object.values(providers).every((x) => typeof x === "string"));
|
|
const verifierSource = await readFile(new URL(import.meta.url));
|
|
const {
|
|
observeOwnedProcessLineage,
|
|
observeProcessStartIdentity,
|
|
systemProcessOwnershipHost: host,
|
|
} = await import(
|
|
pathToFileURL(runtime + "/dist/process/ProcessOwnershipObservation.js")
|
|
);
|
|
const { verifyNoTokenOwnedProcesses } = await import(
|
|
pathToFileURL(runtime + "/dist/process/ProcessOwnership.js")
|
|
);
|
|
const target = values.path;
|
|
const sha = (x) => createHash("sha256").update(x).digest("hex");
|
|
const fileSha = async (path) => {
|
|
const hash = createHash("sha256");
|
|
for await (const part of createReadStream(path)) hash.update(part);
|
|
return hash.digest("hex");
|
|
};
|
|
const before = await fileSha(target);
|
|
const rows = [];
|
|
const roots = async (work) =>
|
|
(await readdir(work))
|
|
.filter((x) => x.startsWith("rea-ghidra-"))
|
|
.map((x) => join(work, x));
|
|
const ownershipSchema = z
|
|
.object({
|
|
run_id: z.string().uuid(),
|
|
pid: z.number().int().positive(),
|
|
process_group_id: z.number().int().positive(),
|
|
parent_pid: z.number().int().positive(),
|
|
launcher: z.string().min(1),
|
|
endpoint_path: z.string().min(1),
|
|
ownership_kind: z.literal("posix-process-group"),
|
|
})
|
|
.passthrough();
|
|
const manifest = async (root) =>
|
|
ownershipSchema.parse(
|
|
JSON.parse(await readFile(join(root, "ownership.json"), "utf8")),
|
|
);
|
|
const verifyWithCleanup = async (verify, cleanups) => {
|
|
const failures = [];
|
|
let result;
|
|
try {
|
|
result = await verify();
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
for (const cleanup of cleanups) {
|
|
try {
|
|
await cleanup();
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if (failures.length > 0)
|
|
throw new AggregateError(
|
|
failures,
|
|
"Verification or resource cleanup failed",
|
|
);
|
|
return result;
|
|
};
|
|
const restoreProject = async (project, mode) => {
|
|
if (project === undefined || mode === undefined) return;
|
|
try {
|
|
await chmod(project, mode);
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") throw error;
|
|
}
|
|
};
|
|
const unreadyLaunch = async (work) => {
|
|
for (const root of await roots(work)) {
|
|
let record;
|
|
try {
|
|
record = await manifest(root);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") continue;
|
|
throw error;
|
|
}
|
|
const lineage = await observeOwnedProcessLineage(owned(record));
|
|
if (lineage.status !== "verified") continue;
|
|
const identity = await observeProcessStartIdentity(
|
|
record.pid,
|
|
undefined,
|
|
record.run_id,
|
|
);
|
|
if (identity?.state !== "readable") continue;
|
|
try {
|
|
await access(record.endpoint_path);
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") throw error;
|
|
return { root, manifest: record, lineage, start_identity: identity };
|
|
}
|
|
}
|
|
};
|
|
const owned = (m) => ({
|
|
runId: m.run_id,
|
|
leaderPid: m.pid,
|
|
processGroupId: m.process_group_id,
|
|
expectedParentPid: m.parent_pid,
|
|
expectedCommand: m.launcher,
|
|
});
|
|
const cancelStartup = async ({ client, defs, work, call }) => {
|
|
const baseline = await host.captureBaseline();
|
|
const controller = new AbortController();
|
|
const pattern = "rea-lifecycle-" + randomUUID();
|
|
let settled = false;
|
|
const pending = client
|
|
.callTool(
|
|
{ name: "search_procedures", arguments: { pattern } },
|
|
{
|
|
toolDefinition: defs.get("search_procedures"),
|
|
timeout,
|
|
signal: controller.signal,
|
|
},
|
|
)
|
|
.then(
|
|
(reply) => {
|
|
settled = true;
|
|
return { reply };
|
|
},
|
|
(error) => {
|
|
settled = true;
|
|
return {
|
|
error: {
|
|
name: error.name,
|
|
message: error.message,
|
|
code: error.code,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
let launch;
|
|
try {
|
|
const deadline = Date.now() + 30000;
|
|
while (Date.now() < deadline && !settled) {
|
|
launch = await unreadyLaunch(work);
|
|
if (launch) break;
|
|
await delay(50);
|
|
}
|
|
assert.ok(launch, "No token-verified live JVM before endpoint readiness");
|
|
assert.equal(settled, false);
|
|
} finally {
|
|
controller.abort(new Error("Preset startup cancellation"));
|
|
await pending;
|
|
}
|
|
const canceled = await pending;
|
|
assert.ok(canceled.error, "Pending SDK request did not cancel");
|
|
await writeFile(work + "/cancellation.json", JSON.stringify(canceled), {
|
|
flag: "wx",
|
|
mode: 0o600,
|
|
});
|
|
let cleanup;
|
|
const deadline = Date.now() + 30000;
|
|
while (Date.now() < deadline) {
|
|
cleanup = await verifyNoTokenOwnedProcesses(
|
|
launch.manifest.run_id,
|
|
undefined,
|
|
baseline,
|
|
{
|
|
leaderPid: launch.manifest.pid,
|
|
processGroupId: launch.manifest.process_group_id,
|
|
},
|
|
);
|
|
const liveRoots = await roots(work);
|
|
if (cleanup.cleaned && liveRoots.length === 0) break;
|
|
await delay(100);
|
|
}
|
|
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
|
|
assert.deepEqual(await roots(work), []);
|
|
await assert.rejects(access(dirname(launch.manifest.endpoint_path)), {
|
|
code: "ENOENT",
|
|
});
|
|
const afterCancel = (await call("binary_session")).result;
|
|
assert.equal(afterCancel.open, true);
|
|
assert.notEqual(afterCancel.analysis_activity.status, "busy");
|
|
return { launch, cleanup, afterCancel, pattern };
|
|
};
|
|
for (const profile of ["default", "disabled"]) {
|
|
const work = lab + "/" + profile;
|
|
await mkdir(work, { mode: 0o700 });
|
|
const client = new Client({
|
|
name: "rea-real-preset-lifecycle",
|
|
version: "1",
|
|
});
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [runtime + "/scripts/rea.mjs", "mcp"],
|
|
env: {
|
|
...process.env,
|
|
...providers,
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
TMPDIR: work,
|
|
REA_GHIDRA_DISABLED_ANALYZERS:
|
|
profile === "default" ? "" : values["disabled-analyzers"],
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
const defs = new Map();
|
|
let count = 0;
|
|
const call = async (name, args = {}, error = false) => {
|
|
const definition = defs.get(name);
|
|
assert.ok(definition, "Missing named contract " + name);
|
|
const reply = await client.callTool(
|
|
{ name, arguments: args },
|
|
{ toolDefinition: definition, timeout },
|
|
);
|
|
await writeFile(
|
|
work + "/call-" + ++count + ".json",
|
|
JSON.stringify(reply),
|
|
{ flag: "wx", mode: 0o600 },
|
|
);
|
|
const value =
|
|
reply.structuredContent ??
|
|
JSON.parse(reply.content.find((x) => x.type === "text").text);
|
|
assert.equal(reply.isError === true, error, JSON.stringify(value));
|
|
return value;
|
|
};
|
|
let project, mode;
|
|
const row = await verifyWithCleanup(async () => {
|
|
await client.connect(transport);
|
|
transport.stderr?.resume();
|
|
for (const t of (await client.listTools()).tools) defs.set(t.name, t);
|
|
await call("open_binary", { path: target, provider_id: "ghidra" });
|
|
const selectedProfile = (await call("binary_session")).result
|
|
.analysis_provider_binding.analysis_profile;
|
|
const { launch, cleanup, afterCancel, pattern } = await cancelStartup({
|
|
client,
|
|
defs,
|
|
work,
|
|
call,
|
|
});
|
|
const recovered = await call("search_procedures", { pattern });
|
|
assert.deepEqual(recovered.normalized_result, []);
|
|
const activeRoots = await roots(work);
|
|
assert.equal(activeRoots.length, 1);
|
|
const active = await manifest(activeRoots[0]);
|
|
const activeIdentity = await observeProcessStartIdentity(
|
|
active.pid,
|
|
undefined,
|
|
active.run_id,
|
|
);
|
|
assert.equal(activeIdentity?.state, "readable");
|
|
assert.notEqual(activeIdentity.identity, launch.start_identity.identity);
|
|
const activeLineage = await observeOwnedProcessLineage(owned(active));
|
|
assert.equal(activeLineage.status, "verified");
|
|
const activeBaseline = await host.captureBaseline();
|
|
project = join(activeRoots[0], "project");
|
|
const info = await lstat(project);
|
|
assert.ok(info.isDirectory() && !info.isSymbolicLink());
|
|
assert.equal(
|
|
info.uid,
|
|
process.getuid(),
|
|
"Fault must affect only this user-owned project",
|
|
);
|
|
mode = info.mode & 0o777;
|
|
await chmod(project, 0o500);
|
|
const refusal = await verifyWithCleanup(
|
|
() => call("close_binary", {}, true),
|
|
[() => restoreProject(project, mode)],
|
|
);
|
|
assert.equal(refusal.error.code, "cleanup_incomplete");
|
|
assert.ok(refusal.error.details.resources.includes(activeRoots[0]));
|
|
await writeFile(work + "/cleanup-refusal.json", JSON.stringify(refusal), {
|
|
flag: "wx",
|
|
mode: 0o600,
|
|
});
|
|
assert.ok(
|
|
(await roots(work)).includes(activeRoots[0]),
|
|
"Failed cleanup resource ownership disappeared",
|
|
);
|
|
await call("close_binary");
|
|
assert.deepEqual(await roots(work), []);
|
|
await assert.rejects(access(dirname(active.endpoint_path)), {
|
|
code: "ENOENT",
|
|
});
|
|
const retried = await verifyNoTokenOwnedProcesses(
|
|
active.run_id,
|
|
undefined,
|
|
activeBaseline,
|
|
{ leaderPid: active.pid, processGroupId: active.process_group_id },
|
|
);
|
|
assert.equal(retried.cleaned, true, JSON.stringify(retried));
|
|
assert.equal((await call("binary_session")).result.open, false);
|
|
assert.equal(await fileSha(target), before);
|
|
return {
|
|
profile,
|
|
selected_profile: selectedProfile,
|
|
source_sha256: before,
|
|
actual_pending_startup_canceled: true,
|
|
verified_launch: launch,
|
|
token_cleanup: cleanup,
|
|
post_cancellation_activity: afterCancel.analysis_activity,
|
|
recovery_preserves_selected_target: true,
|
|
logical_run_id_after_recovery: active.run_id,
|
|
recovered_ownership: active,
|
|
new_verified_process_generation_after_recovery: activeIdentity,
|
|
real_permission_cleanup_failure: refusal,
|
|
retry_cleanup: retried,
|
|
source_bytes_unchanged: true,
|
|
};
|
|
}, [() => restoreProject(project, mode), () => client.close()]);
|
|
rows.push(row);
|
|
await writeFile(
|
|
lab + "/progress.json",
|
|
JSON.stringify(rows, null, 2) + "\n",
|
|
{ mode: 0o600 },
|
|
);
|
|
process.stdout.write(JSON.stringify({ profile, status: "passed" }) + "\n");
|
|
}
|
|
assert.deepEqual(
|
|
await readFile(new URL(import.meta.url)),
|
|
verifierSource,
|
|
"Verifier source changed during execution",
|
|
);
|
|
await writeFile(
|
|
lab + "/summary.json",
|
|
JSON.stringify(
|
|
{
|
|
caller_supplied_runtime_revision: values.revision,
|
|
verifier_source_sha256: sha(verifierSource),
|
|
provider_settings_sha256: sha(providerBytes),
|
|
source_path: target,
|
|
source_sha256: before,
|
|
rows,
|
|
scope:
|
|
"Actual public MCP on Linux: token/start-identity verified pending Ghidra startup cancellation, fresh work under the selected target, controlled permission failure in an owned temporary project, retained-resource close retry and read-only token sweeps. Original input is unchanged. Runtime revision is caller-supplied; this is lifecycle verification, not paired performance or general OS coverage.",
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
{ flag: "wx", mode: 0o600 },
|
|
);
|