mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
* fix(electron): resolve pages served from inside app.asar Packaged Electron applications load their renderer pages from file URLs inside app.asar. The host filesystem cannot stat those paths, so list_electron_targets excluded every such page as unsupported_url and inspect_electron_page could not select it. Resolve these URLs the way Electron's file protocol does: the first .asar path component that is a regular file is the archive, and the remainder must name a regular file in its header. Directories, links, and missing members stay rejected. Parsed headers are cached by archive identity. * fix(artifacts): extract and explain rewritten ASAR unpacked companions Packaging tools commonly sign or strip .asar.unpacked native binaries after writing the archive header, so their declared hashes no longer match. analyze_javascript_application already accepts integrity_policy, but extract_artifact did not, and inspect_artifact and extract_artifact still told callers to "get a fresh copy", which cannot help for a shipped app. - Accept integrity_policy on extract_artifact (MCP and CLI). The default still fails closed; record-and-continue writes the observed bytes and returns the extracted files' integrity_contradictions records. - Name the unpacked entry and the post-packaging cause in integrity failures, and point every operation that accepts integrity_policy at record-and-continue. - Report an active unpacked entry whose companion file is absent as unavailable with its logical path instead of a format failure. - Share one integrity_policy schema across the inventory, extraction, and JavaScript application inputs. * fix(electron): bound ASAR headers before allocation --------- Co-authored-by: morluto <[email protected]>
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { ok as resultOk } from "../../../src/domain/result.js";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { createPackageWithOptions } from "@electron/asar";
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
import { expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
|
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { observed } from "../../fixtures/analysisExecution.js";
|
|
|
|
const integrityMessage = (unpacked: boolean): string =>
|
|
`${
|
|
unpacked
|
|
? "Declared ASAR integrity for unpacked entry main.js contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive."
|
|
: "Artifact bytes contradict declared integrity."
|
|
} If expected, rerun inspect_artifact with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`;
|
|
|
|
it.each([
|
|
{ unpacked: false, label: "embedded" },
|
|
{ unpacked: true, label: "unpacked" },
|
|
])(
|
|
"returns actionable local details for an $label ASAR integrity error",
|
|
async ({ unpacked }) => {
|
|
const root = await createTestTempDirectory("rea-asar-integrity-mcp-");
|
|
const source = join(root, "source");
|
|
const original = "console.log('ok');\n";
|
|
const changed = "console.log('no');\n";
|
|
await mkdir(source);
|
|
await writeFile(join(source, "main.js"), original);
|
|
const archive = join(root, "fixture.asar");
|
|
await createPackageWithOptions(
|
|
source,
|
|
archive,
|
|
unpacked ? { unpack: "*.js" } : {},
|
|
);
|
|
if (unpacked) {
|
|
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
|
} else {
|
|
const bytes = await readFile(archive);
|
|
const contentOffset = bytes.indexOf(original);
|
|
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
|
bytes.write(changed, contentOffset, "utf8");
|
|
await writeFile(archive, bytes);
|
|
}
|
|
|
|
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const opened = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: archive },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
|
|
const result = await client.callTool({
|
|
name: "inspect_artifact",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(parseMcpToolError(result)).toEqual({
|
|
error: {
|
|
code: "artifact_integrity_mismatch",
|
|
category: "integrity_mismatch",
|
|
message: integrityMessage(unpacked),
|
|
details: {
|
|
logical_path: "main.js",
|
|
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
|
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
|
unpacked,
|
|
},
|
|
retryable: false,
|
|
remediation: { action: integrityMessage(unpacked) },
|
|
},
|
|
});
|
|
} finally {
|
|
await Promise.allSettled([
|
|
client.close(),
|
|
server.close(),
|
|
session.close(),
|
|
]);
|
|
}
|
|
},
|
|
20_000,
|
|
);
|
|
|
|
const artifactInventoryResultSchema = z.object({
|
|
occurrences: z.array(
|
|
z.object({ logical_path: z.string(), hash_status: z.string() }),
|
|
),
|
|
integrity_contradictions: z.array(
|
|
z.object({
|
|
logical_path: z.string(),
|
|
declared_sha256: z.string(),
|
|
observed_sha256: z.string(),
|
|
trust: z.literal("observed-untrusted"),
|
|
}),
|
|
),
|
|
});
|
|
|
|
it("extracts an active archive through MCP when requested", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-extract-mcp-");
|
|
const archive = join(root, "fixture.zip");
|
|
const zip = new ZipWriter(new Uint8ArrayWriter());
|
|
await zip.add("main.js", new TextReader("console.log('extract');\n"));
|
|
await writeFile(archive, await zip.close());
|
|
|
|
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({ name: "artifact-extract-test", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
let outputRoot: string | undefined;
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const opened = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: archive },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
|
|
const result = await client.callTool({
|
|
name: "extract_artifact",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
const normalized = z
|
|
.object({ output_root: z.string(), artifacts: z.array(z.unknown()) })
|
|
.parse(toolEvidence(result.structuredContent).normalized_result);
|
|
outputRoot = normalized.output_root;
|
|
expect(await readFile(join(outputRoot, "main.js"), "utf8")).toBe(
|
|
"console.log('extract');\n",
|
|
);
|
|
expect(normalized.artifacts).toHaveLength(1);
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
if (outputRoot !== undefined)
|
|
await rm(outputRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("extracts a macOS app bundle through MCP", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-extract-app-mcp-");
|
|
const contents = join(root, "Fixture.app", "Contents");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
const plist =
|
|
'<?xml version="1.0"?><plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>';
|
|
await writeFile(join(contents, "Info.plist"), plist);
|
|
const header = Buffer.alloc(32);
|
|
header.writeUInt32LE(0xfeedfacf, 0);
|
|
header.writeUInt32LE(0x01000007, 4);
|
|
header.writeUInt32LE(3, 8);
|
|
header.writeUInt32LE(2, 12);
|
|
await writeFile(join(contents, "MacOS", "Fixture"), header);
|
|
|
|
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({
|
|
name: "artifact-extract-app-test",
|
|
version: "1",
|
|
});
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
let outputRoot: string | undefined;
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const opened = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: join(root, "Fixture.app") },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
|
|
const result = await client.callTool({
|
|
name: "extract_artifact",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
const normalized = z
|
|
.object({ output_root: z.string() })
|
|
.parse(toolEvidence(result.structuredContent).normalized_result);
|
|
outputRoot = normalized.output_root;
|
|
expect(
|
|
await readFile(join(outputRoot, "Contents", "Info.plist"), "utf8"),
|
|
).toBe(plist);
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
if (outputRoot !== undefined)
|
|
await rm(outputRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("records an explicitly continued mismatch, preserves verified siblings, and never reports equivalence", async () => {
|
|
const root = await createTestTempDirectory("rea-asar-continue-mcp-");
|
|
const source = join(root, "source");
|
|
await mkdir(source);
|
|
const original = "console.log('ok');\n",
|
|
changed = "console.log('no');\n",
|
|
secondOriginal = "console.log('up');\n",
|
|
secondChanged = "console.log('dn');\n";
|
|
await writeFile(join(source, "main.js"), original);
|
|
await writeFile(join(source, "second.js"), secondOriginal);
|
|
await writeFile(join(source, "sibling.js"), "console.log('sibling');\n");
|
|
const archive = join(root, "fixture.asar");
|
|
await createPackageWithOptions(source, archive, {});
|
|
const bytes = await readFile(archive);
|
|
const contentOffset = bytes.indexOf(original);
|
|
const secondOffset = bytes.indexOf(secondOriginal);
|
|
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
|
expect(secondOffset).toBeGreaterThanOrEqual(0);
|
|
bytes.write(changed, contentOffset, "utf8");
|
|
bytes.write(secondChanged, secondOffset, "utf8");
|
|
await writeFile(archive, bytes);
|
|
|
|
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({ name: "asar-continue-test", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: archive },
|
|
});
|
|
const result = await client.callTool({
|
|
name: "inspect_artifact",
|
|
arguments: {
|
|
integrity_policy: "record-and-continue",
|
|
},
|
|
});
|
|
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
|
|
true,
|
|
);
|
|
const compact = toolEvidence(result.structuredContent);
|
|
const evidence = session.evidenceById(compact.evidence_id);
|
|
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
|
const inspection = z
|
|
.object({
|
|
contradictions: z.array(z.unknown()),
|
|
substeps: z.array(
|
|
z.object({
|
|
evidence: z.object({ normalized_result: z.unknown() }),
|
|
}),
|
|
),
|
|
})
|
|
.parse(compact.normalized_result);
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
inspection.substeps[0]?.evidence.normalized_result,
|
|
);
|
|
expect(inspection.contradictions).toHaveLength(2);
|
|
expect(inventory.integrity_contradictions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "main.js",
|
|
trust: "observed-untrusted",
|
|
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
|
observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
|
}),
|
|
expect.objectContaining({ logical_path: "second.js" }),
|
|
]),
|
|
);
|
|
expect(inventory.integrity_contradictions).toHaveLength(2);
|
|
expect(inventory.occurrences).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "main.js",
|
|
hash_status: "mismatched",
|
|
}),
|
|
expect.objectContaining({
|
|
logical_path: "sibling.js",
|
|
hash_status: "verified",
|
|
}),
|
|
expect.objectContaining({
|
|
logical_path: "second.js",
|
|
hash_status: "mismatched",
|
|
}),
|
|
]),
|
|
);
|
|
const compared = await client.callTool({
|
|
name: "compare_artifacts",
|
|
arguments: {
|
|
left: evidence,
|
|
right: evidence,
|
|
},
|
|
});
|
|
expect(compared.isError).not.toBe(true);
|
|
expect(
|
|
toolEvidence(compared.structuredContent).normalized_result,
|
|
).toMatchObject({
|
|
status: "contradiction",
|
|
summary: { contradiction: 2 },
|
|
changes: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "main.js",
|
|
classification: "contradiction",
|
|
dimensions: ["integrity"],
|
|
}),
|
|
expect.objectContaining({
|
|
logical_path: "second.js",
|
|
classification: "contradiction",
|
|
}),
|
|
]),
|
|
});
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
}
|
|
});
|
|
|
|
it("rejects inline artifact Evidence whose content does not match its ID", async () => {
|
|
const session = createTestBinarySession(() => ({
|
|
health: () => Promise.resolve(),
|
|
execute: () => Promise.resolve(observed(null)),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
}));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({
|
|
name: "artifact-authority-test",
|
|
version: "1",
|
|
});
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const result = await client.callTool({
|
|
name: "compare_artifacts",
|
|
arguments: {
|
|
...ARTIFACT_COMPARISON_EXAMPLE,
|
|
left: {
|
|
...ARTIFACT_COMPARISON_EXAMPLE.left,
|
|
limitations: ["caller altered this record"],
|
|
},
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(
|
|
session.evidenceById(ARTIFACT_COMPARISON_EXAMPLE.left.evidence_id),
|
|
).toBeUndefined();
|
|
expect(
|
|
session.evidenceById(ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id),
|
|
).toBeUndefined();
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
}
|
|
});
|
|
|
|
it("compares inline inventory Evidence without prior session calls", async () => {
|
|
const session = createTestBinarySession(() => ({
|
|
health: () => Promise.resolve(),
|
|
execute: () => Promise.resolve(observed(null)),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
}));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({
|
|
name: "artifact-ownership-test",
|
|
version: "1",
|
|
});
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const result = await client.callTool({
|
|
name: "compare_artifacts",
|
|
arguments: ARTIFACT_COMPARISON_EXAMPLE,
|
|
});
|
|
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
toolEvidence(result.structuredContent).normalized_result,
|
|
).toMatchObject({
|
|
status: "changed",
|
|
});
|
|
expect(
|
|
session.evidenceById(ARTIFACT_COMPARISON_EXAMPLE.left.evidence_id),
|
|
).toBeDefined();
|
|
expect(
|
|
session.evidenceById(ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id),
|
|
).toBeDefined();
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
}
|
|
});
|
|
|
|
it("returns full artifact graphs inline and compares changed inventories", async () => {
|
|
const directory = await createTestTempDirectory("rea-artifact-mcp-");
|
|
const archive = join(directory, "fixture.ipa");
|
|
const changedArchive = join(directory, "changed.ipa");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("Payload/Fixture.app/main.js", new TextReader("main();"));
|
|
await writeFile(archive, await writer.close());
|
|
const changedWriter = new ZipWriter(new Uint8ArrayWriter());
|
|
await changedWriter.add(
|
|
"Payload/Fixture.app/main.js",
|
|
new TextReader("changed();"),
|
|
);
|
|
await writeFile(changedArchive, await changedWriter.close());
|
|
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({ name: "artifact-mcp-test", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const opened = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: archive },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
const inspected = await client.callTool({
|
|
name: "inspect_artifact",
|
|
arguments: {},
|
|
});
|
|
expect(inspected.isError).not.toBe(true);
|
|
const inspectionResult = toolEvidence(inspected.structuredContent);
|
|
const inspectionEvidence = session.evidenceById(
|
|
inspectionResult.evidence_id,
|
|
);
|
|
if (inspectionEvidence === undefined)
|
|
throw new Error("missing inspection Evidence");
|
|
const inspection = z
|
|
.object({
|
|
substeps: z.array(
|
|
z.object({
|
|
evidence_id: z.string(),
|
|
status: z.literal("completed"),
|
|
}),
|
|
),
|
|
coverage: z.object({
|
|
status: z.literal("complete-within-substeps"),
|
|
substeps_completed: z.literal(1),
|
|
}),
|
|
})
|
|
.parse(inspectionResult.normalized_result);
|
|
expect(inspection.substeps).toHaveLength(1);
|
|
expect(
|
|
session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""),
|
|
).toMatchObject({ operation: "inventory_artifact" });
|
|
expect(inspectionEvidence.evidence_links).toEqual([
|
|
inspection.substeps[0]?.evidence_id,
|
|
]);
|
|
const inventory = await client.callTool({
|
|
name: "inspect_artifact",
|
|
arguments: {},
|
|
});
|
|
expect(inventory.isError).not.toBe(true);
|
|
const inventoryResult = toolEvidence(inventory.structuredContent);
|
|
const evidence = session.evidenceById(inventoryResult.evidence_id);
|
|
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
|
expect(evidence).toMatchObject({
|
|
provider: { id: "rea-artifact-graph" },
|
|
subject: { format: "ipa" },
|
|
});
|
|
const inventoryInspection = z
|
|
.object({
|
|
substeps: z.array(z.object({ evidence: z.unknown() })),
|
|
})
|
|
.parse(inventoryResult.normalized_result);
|
|
const inventoryEvidence = z
|
|
.object({
|
|
normalized_result: z.object({
|
|
manifest: z.object({ root_format: z.literal("ipa") }),
|
|
occurrences: z.array(z.object({ logical_path: z.string() })),
|
|
}),
|
|
})
|
|
.parse(inventoryInspection.substeps[0]?.evidence);
|
|
expect(inventoryEvidence.normalized_result.occurrences).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "Payload/Fixture.app/main.js",
|
|
}),
|
|
]),
|
|
);
|
|
const openedChanged = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: changedArchive },
|
|
});
|
|
expect(openedChanged.isError).not.toBe(true);
|
|
const changedInventory = await client.callTool({
|
|
name: "inspect_artifact",
|
|
arguments: {},
|
|
});
|
|
const changedResult = toolEvidence(changedInventory.structuredContent);
|
|
const changedInspection = z
|
|
.object({
|
|
substeps: z.array(z.object({ evidence: z.unknown() })),
|
|
})
|
|
.parse(changedResult.normalized_result);
|
|
const compared = await client.callTool({
|
|
name: "compare_artifacts",
|
|
arguments: {
|
|
left: inventoryInspection.substeps[0]?.evidence,
|
|
right: changedInspection.substeps[0]?.evidence,
|
|
},
|
|
});
|
|
expect(compared.isError).not.toBe(true);
|
|
expect(toolEvidence(compared.structuredContent)).toMatchObject({
|
|
normalized_result: { status: "changed" },
|
|
evidence_id: expect.stringMatching(/^ev_/u),
|
|
});
|
|
const unknowns = await client.callTool({
|
|
name: "list_unknowns",
|
|
arguments: { domain: "artifact-comparison" },
|
|
});
|
|
expect(unknowns.structuredContent).toMatchObject({
|
|
result: {
|
|
items: [
|
|
expect.objectContaining({
|
|
domain: "artifact-comparison",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
} finally {
|
|
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
|
}
|
|
});
|
|
|
|
const toolEvidence = (value: unknown) => parseEvidence(value);
|