Reuse the schema-visible host path owner across session, firmware, managed, browser, Electron and Evidence-file inputs. Keep existing runtime and CLI path semantics. Refs #5493.
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
import { resolve } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { silentLogger } from "../../../src/logger.js";
|
|
|
|
interface PathInputCase {
|
|
readonly name: string;
|
|
readonly field: string;
|
|
readonly valid: Readonly<Record<string, unknown>>;
|
|
readonly invalid: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
const createMcp = async (firmwareCalls: string[]) => {
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("Invalid path input must not acquire a binary provider");
|
|
});
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{
|
|
logger: silentLogger,
|
|
firmwareAnalysis: {
|
|
execute: async (request) => {
|
|
firmwareCalls.push(request.operation);
|
|
throw new Error("Invalid path input reached firmware analysis");
|
|
},
|
|
close: async () => {},
|
|
},
|
|
},
|
|
);
|
|
const client = new Client({ name: "absolute-path-schema", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
|
|
const close = async (): Promise<void> => {
|
|
const failures: unknown[] = [];
|
|
for (const release of [
|
|
() => client.close(),
|
|
() => server.close(),
|
|
() => session.close(),
|
|
]) {
|
|
try {
|
|
await release();
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
}
|
|
}
|
|
if (failures.length > 0)
|
|
throw new AggregateError(failures, "MCP path fixture cleanup failed");
|
|
};
|
|
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return { client, close };
|
|
} catch (cause: unknown) {
|
|
await close();
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const inputCases = (
|
|
absolute: string,
|
|
relative: string,
|
|
): readonly PathInputCase[] => {
|
|
const pathCase = (
|
|
name: string,
|
|
field: string,
|
|
valid: Readonly<Record<string, unknown>>,
|
|
invalid: Readonly<Record<string, unknown>>,
|
|
): PathInputCase => ({ name, field, valid, invalid });
|
|
|
|
return [
|
|
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
|
|
pathCase(
|
|
"open_binary",
|
|
"snapshot_path",
|
|
{ path: absolute, snapshot_path: absolute },
|
|
{ path: absolute, snapshot_path: relative },
|
|
),
|
|
pathCase(
|
|
"close_binary",
|
|
"snapshot_path",
|
|
{ snapshot_path: absolute },
|
|
{ snapshot_path: relative },
|
|
),
|
|
pathCase(
|
|
"export_evidence_bundle",
|
|
"path",
|
|
{ path: absolute },
|
|
{ path: relative },
|
|
),
|
|
pathCase(
|
|
"binary_session",
|
|
"expected_server_path",
|
|
{ expected_server_path: absolute },
|
|
{ expected_server_path: relative },
|
|
),
|
|
...[
|
|
"inspect_managed_artifact",
|
|
"inspect_managed_members",
|
|
"inspect_managed_native_boundaries",
|
|
].map((name) =>
|
|
pathCase(name, "path", { path: absolute }, { path: relative }),
|
|
),
|
|
pathCase(
|
|
"inspect_firmware_regions",
|
|
"path",
|
|
{ path: absolute },
|
|
{ path: relative },
|
|
),
|
|
pathCase(
|
|
"extract_firmware",
|
|
"path",
|
|
{ path: absolute, output_directory: absolute },
|
|
{ path: relative, output_directory: absolute },
|
|
),
|
|
pathCase(
|
|
"extract_firmware",
|
|
"output_directory",
|
|
{ path: absolute, output_directory: absolute },
|
|
{ path: absolute, output_directory: relative },
|
|
),
|
|
pathCase(
|
|
"inspect_analysis_view",
|
|
"source.path",
|
|
{
|
|
source: { kind: "evidence-file", path: absolute },
|
|
view: { kind: "summary" },
|
|
},
|
|
{
|
|
source: { kind: "evidence-file", path: relative },
|
|
view: { kind: "summary" },
|
|
},
|
|
),
|
|
pathCase(
|
|
"capture_browser_scenario",
|
|
"browser.executable_path",
|
|
{
|
|
browser: { mode: "launch", executable_path: absolute },
|
|
start_url: { url: "https://example.test/" },
|
|
actions: [
|
|
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
|
|
],
|
|
},
|
|
{
|
|
browser: { mode: "launch", executable_path: relative },
|
|
start_url: { url: "https://example.test/" },
|
|
actions: [
|
|
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
|
|
],
|
|
},
|
|
),
|
|
...["executable_path", "application_path", "application_root"].map(
|
|
(field) =>
|
|
pathCase(
|
|
"capture_electron_scenario",
|
|
field,
|
|
{
|
|
executable_path: absolute,
|
|
application_path: absolute,
|
|
[field]: absolute,
|
|
},
|
|
{
|
|
executable_path: absolute,
|
|
application_path: absolute,
|
|
[field]: relative,
|
|
},
|
|
),
|
|
),
|
|
];
|
|
};
|
|
|
|
it("advertises host-absolute path constraints after SDK schema conversion", async () => {
|
|
const firmwareCalls: string[] = [];
|
|
const { client, close } = await createMcp(firmwareCalls);
|
|
try {
|
|
const listed = await client.listTools();
|
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
|
const absolute = resolve("rea-absolute-input");
|
|
const relative = "relative-input";
|
|
|
|
for (const { name, field, valid, invalid } of inputCases(
|
|
absolute,
|
|
relative,
|
|
)) {
|
|
const schema = listed.tools.find(
|
|
(tool) => tool.name === name,
|
|
)?.inputSchema;
|
|
if (schema === undefined)
|
|
throw new Error(`Missing advertised tool ${name}`);
|
|
const validate = ajv.compile(schema);
|
|
expect(validate(invalid), `${name}.${field}`).toBe(false);
|
|
expect(
|
|
validate(valid),
|
|
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
|
|
).toBe(true);
|
|
}
|
|
expect(firmwareCalls).toEqual([]);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
it("rejects relative paths through in-memory MCP handlers", async () => {
|
|
const firmwareCalls: string[] = [];
|
|
const { client, close } = await createMcp(firmwareCalls);
|
|
try {
|
|
for (const { name, field, invalid } of inputCases(
|
|
resolve("rea-absolute-input"),
|
|
"relative-input",
|
|
)) {
|
|
const result = await client.callTool({ name, arguments: invalid });
|
|
expect(result.isError, `${name}.${field}`).toBe(true);
|
|
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
|
|
}
|
|
expect(firmwareCalls).toEqual([]);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|