375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js";
|
|
import { BinarySession } from "../src/application/BinarySession.js";
|
|
import { createPermissionAuthority } from "../src/application/PermissionAuthority.js";
|
|
import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js";
|
|
import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../src/contracts/managedWorkflowExamples.js";
|
|
import type { PermissionCeiling } from "../src/domain/permissionPolicy.js";
|
|
import { ManagedStaticProvider } from "../src/dotnet/ManagedStaticProvider.js";
|
|
import { createServer } from "../src/server/createServer.js";
|
|
import { buildManagedPeFixture } from "./fixtures/managedPe.js";
|
|
|
|
describe("managed artifact MCP tools", () => {
|
|
it("opens a managed PE and executes the managed static provider through MCP", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "rea-managed-mcp-"));
|
|
const path = join(directory, "fixture.exe");
|
|
const rightPath = join(directory, "fixture-renamed.exe");
|
|
const runtimePath = join(directory, "dotnet");
|
|
await writeFile(path, buildManagedPeFixture());
|
|
await writeFile(runtimePath, "#!/bin/sh\n");
|
|
await writeFile(
|
|
rightPath,
|
|
buildManagedPeFixture({ methodName: "Renamed" }),
|
|
);
|
|
const session = new BinarySession(
|
|
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [
|
|
new ManagedStaticProvider(),
|
|
]),
|
|
);
|
|
const runtimeCeiling: PermissionCeiling = {
|
|
capability: "managed_runtime",
|
|
roots: [directory],
|
|
executables: [runtimePath],
|
|
environment_names: [],
|
|
network: "none",
|
|
mount: false,
|
|
};
|
|
const authority = await createPermissionAuthority(
|
|
[runtimeCeiling],
|
|
[
|
|
{
|
|
...runtimeCeiling,
|
|
grant_id: "administrator:managed_runtime",
|
|
lifetime: "administrator",
|
|
operation_identity: null,
|
|
expires_at: null,
|
|
},
|
|
],
|
|
);
|
|
if (!authority.ok) throw authority.error;
|
|
const server = createServer(session, session, {
|
|
permissionAuthority: authority.value,
|
|
managedRuntimePolicy: {
|
|
enabled: true,
|
|
roots: [directory],
|
|
executablePath: runtimePath,
|
|
},
|
|
availabilityPolicy: () => ({
|
|
processCaptureEnabled: false,
|
|
evidenceFileRoots: 0,
|
|
browserObservationEnabled: false,
|
|
electronObservationEnabled: false,
|
|
javascriptReplayEnabled: false,
|
|
managedRuntimeEnabled: true,
|
|
}),
|
|
});
|
|
const client = new Client({ name: "managed-mcp-test", version: "1.0.0" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const tools = await client.listTools();
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"inspect_managed_artifact",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"inspect_managed_members",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"inspect_managed_native_boundaries",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"compare_managed_members",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"import_managed_reconstruction",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"verify_managed_native_boundaries",
|
|
);
|
|
expect(tools.tools.map(({ name }) => name)).toContain(
|
|
"plan_managed_runtime_correlation",
|
|
);
|
|
for (const evidence of [
|
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries,
|
|
...MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations,
|
|
])
|
|
expect(session.recordEvidence(evidence)).toMatchObject({ ok: true });
|
|
const verifiedNative = structured(
|
|
await client.callTool({
|
|
name: "verify_managed_native_boundaries",
|
|
arguments: {
|
|
managed_boundaries_evidence_id:
|
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries
|
|
.evidence_id,
|
|
native_observation_evidence_ids:
|
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations.map(
|
|
({ evidence_id: evidenceId }) => evidenceId,
|
|
),
|
|
limits: MANAGED_NATIVE_VERIFICATION_EXAMPLE.limits,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(verifiedNative).toMatchObject({
|
|
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
|
evidence_uri: expect.stringMatching(/^rea:\/\/evidence\/ev_/u),
|
|
result: {
|
|
summary: { verified: 1 },
|
|
algorithm: { token_to_address_mapping: "not-inferred" },
|
|
},
|
|
});
|
|
const nativeEvidence =
|
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0];
|
|
if (nativeEvidence === undefined)
|
|
throw new Error("missing native Evidence");
|
|
const wrongManagedReference = await client.callTool({
|
|
name: "verify_managed_native_boundaries",
|
|
arguments: {
|
|
managed_boundaries_evidence_id: nativeEvidence.evidence_id,
|
|
native_observation_evidence_ids: [
|
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id,
|
|
],
|
|
},
|
|
});
|
|
expect(wrongManagedReference).toMatchObject({
|
|
isError: true,
|
|
structuredContent: {
|
|
error: {
|
|
code: "evidence_integrity_mismatch",
|
|
details: {
|
|
reason: "wrong_operation",
|
|
actual: "inspect_macho",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path },
|
|
});
|
|
const inspected = structured(
|
|
await client.callTool({
|
|
name: "inspect_managed_artifact",
|
|
arguments: { reference_limit: 1 },
|
|
}),
|
|
);
|
|
|
|
expect(inspected).toMatchObject({
|
|
result: {
|
|
classification: {
|
|
status: "managed",
|
|
runtime_family: "modern-dotnet",
|
|
},
|
|
references: { limit: 1 },
|
|
},
|
|
});
|
|
|
|
const membersResult = structured(
|
|
await client.callTool({
|
|
name: "inspect_managed_members",
|
|
arguments: { method_limit: 1 },
|
|
}),
|
|
);
|
|
const members = sessionEvidence(session, membersResult);
|
|
|
|
expect(members).toMatchObject({
|
|
operation: "inspect_managed_members",
|
|
provider: { id: "rea-dotnet-static" },
|
|
subject: { local_path: path, format: "pe" },
|
|
normalized_result: {
|
|
identity_scope: { token_identity: "build-local" },
|
|
methods: { total: 1, returned: 1 },
|
|
call_edges: { total: 1 },
|
|
field_accesses: { total: 1 },
|
|
},
|
|
});
|
|
|
|
const boundaries = structured(
|
|
await client.callTool({
|
|
name: "inspect_managed_native_boundaries",
|
|
arguments: { import_limit: 1 },
|
|
}),
|
|
);
|
|
|
|
expect(boundaries).toMatchObject({
|
|
result: {
|
|
identity_scope: { token_identity: "build-local" },
|
|
pinvoke_imports: { total: 0, limit: 1 },
|
|
native_implementations: { total: 0 },
|
|
},
|
|
});
|
|
|
|
await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: rightPath },
|
|
});
|
|
const rightMembersResult = structured(
|
|
await client.callTool({
|
|
name: "inspect_managed_members",
|
|
arguments: { method_limit: 1 },
|
|
}),
|
|
);
|
|
const rightMembers = sessionEvidence(session, rightMembersResult);
|
|
const comparedResult = structured(
|
|
await client.callTool({
|
|
name: "compare_managed_members",
|
|
arguments: {
|
|
left_evidence_id: members.evidence_id,
|
|
right_evidence_id: rightMembers.evidence_id,
|
|
limits: {
|
|
max_method_matches: 100,
|
|
max_field_matches: 100,
|
|
max_candidates: 10,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const compared = sessionEvidence(session, comparedResult);
|
|
|
|
expect(compared).toMatchObject({
|
|
operation: "compare_managed_members",
|
|
provider: { id: "rea-dotnet-workflows" },
|
|
confidence: "inferred",
|
|
normalized_result: {
|
|
algorithm: { name_matching: "not-used" },
|
|
matching: { exact_il_signature: 1 },
|
|
},
|
|
});
|
|
|
|
const method = z
|
|
.record(z.string(), z.unknown())
|
|
.parse(members.normalized_result).methods;
|
|
const methodItem = z
|
|
.object({
|
|
items: z.array(
|
|
z.object({
|
|
token: z.string(),
|
|
signature: z.object({ raw_sha256: z.string() }),
|
|
body: z.object({ normalized_il_sha256: z.string().nullable() }),
|
|
}),
|
|
),
|
|
})
|
|
.parse(method).items[0];
|
|
expect(methodItem).toBeDefined();
|
|
if (methodItem === undefined) return;
|
|
const importedResult = structured(
|
|
await client.callTool({
|
|
name: "import_managed_reconstruction",
|
|
arguments: {
|
|
static_members_evidence_id: members.evidence_id,
|
|
decompiler: {
|
|
name: "ilspycmd",
|
|
version: "9.1.0.7988",
|
|
family: "ilspy",
|
|
executable_sha256: null,
|
|
options: ["--type", "Example.Program"],
|
|
},
|
|
methods: [
|
|
{
|
|
token: methodItem.token,
|
|
signature_sha256: methodItem.signature.raw_sha256,
|
|
normalized_il_sha256: methodItem.body.normalized_il_sha256,
|
|
reconstruction: {
|
|
kind: "decompiled-csharp",
|
|
language: "csharp",
|
|
text: "internal static void Main() { }",
|
|
},
|
|
},
|
|
],
|
|
notes: ["synthetic MCP import"],
|
|
},
|
|
}),
|
|
);
|
|
const imported = sessionEvidence(session, importedResult);
|
|
|
|
expect(imported).toMatchObject({
|
|
operation: "import_managed_reconstruction",
|
|
provider: { id: "rea-dotnet-workflows" },
|
|
confidence: "inferred",
|
|
normalized_result: {
|
|
executed: false,
|
|
summary: { imported_methods: 1, decompiled_csharp_methods: 1 },
|
|
methods: [
|
|
{
|
|
token: methodItem.token,
|
|
validation: { canonical_observation: false },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const plannedResult = structured(
|
|
await client.callTool({
|
|
name: "plan_managed_runtime_correlation",
|
|
arguments: {
|
|
static_members_evidence_id: members.evidence_id,
|
|
method: {
|
|
token: methodItem.token,
|
|
signature_sha256: methodItem.signature.raw_sha256,
|
|
normalized_il_sha256: methodItem.body.normalized_il_sha256,
|
|
},
|
|
requested_effect: "attach",
|
|
host: {
|
|
os: "linux",
|
|
clr_family: "dotnet",
|
|
architecture: "x86_64",
|
|
},
|
|
bounds: {
|
|
timeout_ms: 5000,
|
|
max_threads: 32,
|
|
max_output_bytes: 65536,
|
|
allow_network: false,
|
|
allow_ui: false,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const planned = sessionEvidence(session, plannedResult);
|
|
|
|
expect(planned).toMatchObject({
|
|
operation: "plan_managed_runtime_correlation",
|
|
provider: { id: "rea-dotnet-workflows" },
|
|
confidence: "derived",
|
|
normalized_result: {
|
|
executed: false,
|
|
authority_model: { capability: "managed_runtime" },
|
|
requested_runtime: { effect: "attach", network: "none" },
|
|
effect_taxonomy: { attaches_process: true },
|
|
},
|
|
});
|
|
} finally {
|
|
await Promise.all([client.close(), server.close()]);
|
|
await session.close();
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
const structured = (result: CallToolResult): Record<string, unknown> => {
|
|
if (
|
|
typeof result.structuredContent !== "object" ||
|
|
result.structuredContent === null
|
|
)
|
|
throw new Error("missing structured result");
|
|
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
|
};
|
|
|
|
const sessionEvidence = (
|
|
session: BinarySession,
|
|
result: Readonly<Record<string, unknown>>,
|
|
): Record<string, unknown> => {
|
|
const evidenceId = z.string().parse(result.evidence_id);
|
|
const evidence = session.evidenceById(evidenceId);
|
|
if (evidence === undefined) throw new Error("missing session Evidence");
|
|
return z.record(z.string(), z.unknown()).parse(evidence);
|
|
};
|