mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 09:21:16 +00:00
fix(mcp): advertise existing absolute path constraints
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.
This commit is contained in:
1 parent
ee12d517a2
commit
0e83d92c58
9 files changed
+256
-86
No files matched your search
@@ -1,20 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { managedOutputSchemas } from "../toolOutputSchemaGroups.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||
import { localPathStringSchema } from "../../domain/localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../../domain/localPath.js";
|
||||
import { analysisDetailSchema } from "../analysisDetail.js";
|
||||
|
||||
/** Exact caller boundary for execution-free PE/CLI triage and identity. */
|
||||
export const managedTargetInputSchema = z.strictObject({
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/Example.dll or C:\\Example.dll)",
|
||||
})
|
||||
path: absoluteLocalPathStringSchema
|
||||
.describe(
|
||||
"Explicit absolute managed PE/CLI path; omit to reuse the previously selected managed target",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { executableFormatHintSchema } from "../domain/dosCom.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
|
||||
import {
|
||||
binaryArchitectureSchema,
|
||||
rawBinaryEntryPointSchema,
|
||||
@@ -10,24 +9,14 @@ import { z } from "zod";
|
||||
import { analysisProviderSelectorSchema } from "./providerSelection.js";
|
||||
|
||||
/** Caller-supplied analysis snapshot path; must name its destination absolutely. */
|
||||
const snapshotPathSchema = localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"snapshot_path must be an absolute local filesystem path (for example /tmp/rea/analysis.json or C:\\rea\\analysis.json)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local filesystem path for the analysis snapshot; relative paths are rejected.",
|
||||
);
|
||||
const snapshotPathSchema = absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the analysis snapshot; relative paths are rejected.",
|
||||
);
|
||||
|
||||
/** Caller-supplied analysis target path; must name the target absolutely. */
|
||||
const binaryTargetPathSchema = localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/fixture.bin or C:\\analysis\\fixture.bin)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local filesystem path for the analysis target; relative paths are rejected.",
|
||||
);
|
||||
const binaryTargetPathSchema = absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the analysis target; relative paths are rejected.",
|
||||
);
|
||||
|
||||
/** Input contract for opening a target with an optional staged snapshot. */
|
||||
export const openBinaryInputSchema = z
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
|
||||
|
||||
/** Optional expectations used to compare the live session with its caller. */
|
||||
export const binarySessionInputSchema = z.strictObject({
|
||||
@@ -10,11 +8,7 @@ export const binarySessionInputSchema = z.strictObject({
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.optional(),
|
||||
expected_server_path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"expected_server_path must be an absolute local filesystem path (for example /opt/rea/dist/main.js)",
|
||||
})
|
||||
expected_server_path: absoluteLocalPathStringSchema
|
||||
.describe(
|
||||
"Absolute local filesystem path expected for the running server; relative paths are rejected.",
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { matchNativeFunctionsInputSchema } from "./native/functionMatchingContracts.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAbsolute } from "node:path";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
|
||||
import { artifactComparisonInputSchema } from "../domain/artifactComparison.js";
|
||||
import { callPathInputSchema } from "../domain/callPathSchemas.js";
|
||||
import { changedBehaviorInputSchema } from "../domain/changedBehavior.js";
|
||||
@@ -55,14 +54,9 @@ const session = <
|
||||
|
||||
/** Session-owned Evidence bundle export options. */
|
||||
export const exportEvidenceBundleInputSchema = z.strictObject({
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/rea/evidence.json or C:\\rea\\evidence.json)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local filesystem path for the exported evidence bundle; relative paths are rejected.",
|
||||
),
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the exported evidence bundle; relative paths are rejected.",
|
||||
),
|
||||
overwrite: z.boolean().default(false),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
NATIVE_INVENTORY_OPERATIONS,
|
||||
nativeInventoryOperationSchema,
|
||||
} from "../native/nativeInventory.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../localPath.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
desktopViewRequestSchema,
|
||||
@@ -297,14 +296,9 @@ export const analysisViewSourceSchema = z.discriminatedUnion("kind", [
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("evidence-file"),
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/capture.json or C:\\rea\\capture.json)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local path of one completed Evidence JSON file. REA reads and authenticates it, then projects it like retained Evidence, so large captures are not charged as inline input.",
|
||||
),
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local path of one completed Evidence JSON file. REA reads and authenticates it, then projects it like retained Evidence, so large captures are not charged as inline input.",
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
||||
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "./localPath.js";
|
||||
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
|
||||
import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js";
|
||||
import {
|
||||
@@ -18,11 +17,6 @@ import {
|
||||
|
||||
export const scenarioIdentifierSchema = z.string().regex(IDENTIFIER_PATTERN);
|
||||
|
||||
const browserExecutablePathSchema = localPathStringSchema.refine(isAbsolute, {
|
||||
message:
|
||||
"executable_path must be an absolute local filesystem path (for example /opt/chromium/chrome or C:\\chromium\\chrome.exe)",
|
||||
});
|
||||
|
||||
const browserScenarioBaseUrlSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -77,7 +71,7 @@ export type BrowserScenarioUrl = z.infer<typeof browserScenarioUrlSchema>;
|
||||
export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [
|
||||
z.strictObject({
|
||||
mode: z.literal("launch"),
|
||||
executable_path: browserExecutablePathSchema.describe(
|
||||
executable_path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the browser executable; relative paths are rejected.",
|
||||
),
|
||||
headless: z
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { absoluteLocalPathStringSchema } from "../localPath.js";
|
||||
|
||||
const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const range = z.strictObject({ offset: bytes, length: bytes.positive() });
|
||||
const target = {
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/firmware.bin or C:\\firmware\\firmware.bin)",
|
||||
})
|
||||
.describe(
|
||||
"Absolute local firmware file; never executed. Relative paths are rejected.",
|
||||
),
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local firmware file; never executed. Relative paths are rejected.",
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicit firmware inspection and extraction intent, independent of engines. */
|
||||
@@ -22,11 +16,9 @@ export const firmwareInputSchemas = {
|
||||
inspect_firmware_regions: z.strictObject(target),
|
||||
extract_firmware: z.strictObject({
|
||||
...target,
|
||||
output_directory: localPathStringSchema
|
||||
.refine(isAbsolute, {
|
||||
message: "output_directory must be an absolute local filesystem path",
|
||||
})
|
||||
.describe("Absolute, absent output directory; parent must exist"),
|
||||
output_directory: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute, absent output directory; parent must exist",
|
||||
),
|
||||
range: range
|
||||
.optional()
|
||||
.describe("Explicit file-byte interval; omit to extract the whole file"),
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { absoluteLocalPathStringSchema } from "../localPath.js";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const pathInputSchema = localPathStringSchema.refine(isAbsolute, {
|
||||
message:
|
||||
"Electron executable, application, and root paths must be absolute local filesystem paths (for example /Applications/Electron.app/Contents/MacOS/Electron)",
|
||||
});
|
||||
const absolutePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -66,13 +60,13 @@ const actionSchema = z.discriminatedUnion("kind", [
|
||||
|
||||
/** Input for one explicit, provider-owned Electron runtime experiment. */
|
||||
export const electronActiveObservationInputSchema = z.strictObject({
|
||||
executable_path: pathInputSchema.describe(
|
||||
executable_path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the Electron executable; relative paths are rejected.",
|
||||
),
|
||||
application_path: pathInputSchema.describe(
|
||||
application_path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute local filesystem path for the Electron application; relative paths are rejected.",
|
||||
),
|
||||
application_root: pathInputSchema
|
||||
application_root: absoluteLocalPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute local filesystem root for application-relative paths; omit to derive it from the application path. Relative paths are rejected.",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user