fix(contracts)!: require absolute local paths for remaining caller-supplied file inputs (#1004)

* fix(contracts)!: require absolute local paths for remaining caller-supplied file inputs

Follow-up to the session snapshot/export tightening: the same invisible server-cwd resolution applied to open_binary target paths (parseBinaryTarget(path, process.cwd())), import_evidence_bundle bundle reads, firmware input files (resolve(raw)), browser executable launch paths (resolve-then-spawn), Electron executable/application/root paths (realpath on raw input), and the expected server path check.

Each field gains the shared isAbsoluteLocalPath refinement plus a catalog-visible description, since JSON Schema cannot express the rule. Examples, scenario fixtures, and contract tests updated to absolute paths; new schema cases cover rejection/acceptance/omission per field.

Explicitly unchanged (verified rooted or out of scope): inspect_plist paths (resolved against the target bundle dir), firmware output_directory (already provider-enforced), filesystem observation paths (resolved against the caller-supplied working_directory), keyed-archive member paths (archive-relative), JS evidence member paths (evidence-relative under root_alias), provider-internal execution schemas, CLI-only doctor flows, and CLI options (own resolve against the operator cwd).

* fix(cli): resolve operator-relative paths before shared absolute-path validation

The contract tightening rejects relative paths, but three CLI commands forward operator-relative input into the shared schemas: firmware commands pass raw args to FirmwareAnalysisService, and the Electron/browser scenario commands validate parsed scenario JSON directly. CLI paths are relative to the operator shell, so resolve them against the working directory first: resolve() in firmwareCommands, and a shared resolveCliJsonPaths JSON key-path helper (cliJsonInput) applied in electronCommands and cliBrowserScenarioCommands. MCP behavior is unchanged — MCP callers must still send absolute paths.

Covers the regression with a relative-path firmware CLI acceptance test (asserts provider-stage capability_unavailable, not a validation error) and unit tests for the helper.
This commit is contained in:
morluto 2026-10-08 00:52:37 +08:00 committed by GitHub
parent eb8f6491a8
commit cbc0dd1e7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 350 additions and 27 deletions

View File

@ -675,9 +675,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "8816080083d86cb4c05db8d1ef452938abef2e6fdeead566252592ffbb55d828",
"tools_sha256": "c77a3b4740fc2e78172e1d6757b1915d2b638185d8e87d2f1885bb41ce538813",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "9b4e896d2585645f1ed1f9f3eef270cf798f08035c70091178b7bd6119b22bf5",
"combined_sha256": "e652c2ec4cde078a23735789c1d01cb1b9d316c9af7708e86f2c5c31b37b0e51",
"providers_sha256": "aea15f517c822d80918ccaba57a4fa06557f484cf27b8acbaed4e1a660fac896"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_82b2f9406099c2d152fcfa020c3acb23deb22d82405d42a610553e2bd0da94cc",
"manifest_id": "ecm_aa4a1c78be657e901c87cc215a703938cfe54d7a54411cbfd64872de3ce78b93",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "77c5acdd9315573e4a1a0843e4ca643626674d2872729fd5db491261d386f110"
"sha256": "57f28ace603b9eb5d6d9ea64d68f563ea8a4833e6a53dd7b4b40ce5fb3ac56e1"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "27"
tool_count: 133
catalog_digest: "9b4e896d2585645f1ed1f9f3eef270cf798f08035c70091178b7bd6119b22bf5"
catalog_digest: "e652c2ec4cde078a23735789c1d01cb1b9d316c9af7708e86f2c5c31b37b0e51"
---
# REA

View File

@ -7,7 +7,7 @@ import { browserScenarioSchema } from "../domain/browserScenario.js";
it("uses exact accessible-name matching without a caller confirmation field", async () => {
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "chrome" },
browser: { mode: "launch", executable_path: "/opt/chromium/chrome" },
start_url: { url: "https://app.example.test/" },
actions: [
{

View File

@ -18,7 +18,7 @@ import { projectInputIssues } from "../domain/inputIssueProjection.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Logger } from "../logger.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js";
import {
electronPageInspectionOptions,
javascriptApplicationOptions,
@ -57,7 +57,11 @@ const registerElectronActiveCommand = (
);
if (!input.ok) return input.error;
const parsed = electronActiveObservationInputSchema.safeParse(
input.value,
resolveCliJsonPaths(input.value, [
["executable_path"],
["application_path"],
["application_root"],
]),
);
if (!parsed.success)
return inputError(

View File

@ -1,4 +1,5 @@
import { z } from "incur";
import { resolve } from "node:path";
import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js";
import { createFirmwareAnalysisProvider } from "../composition/firmware.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@ -38,7 +39,8 @@ export const registerFirmwareCommands = (
execute(
CLI_COMMANDS.inspectFirmwareRegions,
"inspect_firmware_regions",
args,
// CLI paths are operator-relative; resolve before shared validation.
{ path: resolve(args.path) },
),
});
cli.command(CLI_COMMANDS.extractFirmware, {
@ -81,8 +83,8 @@ export const registerFirmwareCommands = (
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.extractFirmware, "extract_firmware", {
path: args.path,
output_directory: args.outputDirectory,
path: resolve(args.path),
output_directory: resolve(args.outputDirectory),
...(options.offset === undefined && options.length === undefined
? {}
: { range: { offset: options.offset, length: options.length } }),

View File

@ -4,7 +4,7 @@ import { z } from "zod";
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
import { createBrowserScenarioProvider } from "./composition/browserScenario.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { parseCliJsonInput } from "./cliJsonInput.js";
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
import { logCliCommand } from "./cliLogging.js";
import { AnalysisInputError } from "./domain/analysisErrorCore.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
@ -32,7 +32,9 @@ export const registerBrowserScenarioCommands = (
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
const input = await parseCliJsonInput(args.inputJson, OPERATION);
if (!input.ok) return input.error;
const scenario = browserScenarioSchema.safeParse(input.value);
const scenario = browserScenarioSchema.safeParse(
resolveCliJsonPaths(input.value, [["browser", "executable_path"]]),
);
if (!scenario.success)
return cliError(
new AnalysisInputError(

View File

@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { AnalysisInputError } from "./domain/analysisErrorCore.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
@ -33,6 +34,40 @@ export const parseCliJsonInput = async (
}
};
/**
* Resolve named string fields of CLI-supplied JSON against the operator
* working directory. Each entry is a key path from the document root; only
* fields holding strings are resolved, everything else passes through
* untouched. Shared MCP contracts reject relative paths, so CLI workflows
* resolve operator-relative values before validation.
*/
export const resolveCliJsonPaths = (
value: unknown,
paths: ReadonlyArray<ReadonlyArray<string>>,
): unknown => {
let resolved = value;
for (const keys of paths) resolved = resolveJsonPath(resolved, keys);
return resolved;
};
const resolveJsonPath = (
value: unknown,
keys: ReadonlyArray<string>,
): unknown => {
const [head, ...tail] = keys;
if (typeof value !== "object" || value === null || Array.isArray(value))
return value;
const entries = Object.entries(value).map(
([key, entry]: readonly [string, unknown]): readonly [string, unknown] => {
if (key !== head) return [key, entry];
if (tail.length === 0)
return [key, typeof entry === "string" ? resolve(entry) : entry];
return [key, resolveJsonPath(entry, tail)];
},
);
return Object.fromEntries(entries);
};
const cannotBeAnExistingFile = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&

View File

@ -15,9 +15,21 @@ const snapshotPathSchema = z
"Absolute local filesystem path for the analysis snapshot; relative paths are rejected.",
);
/** Caller-supplied analysis target path; must name the target absolutely. */
const binaryTargetPathSchema = z
.string()
.min(1)
.refine(isAbsoluteLocalPath, {
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.",
);
/** Input contract for opening a target with an optional staged snapshot. */
export const openBinaryInputSchema = z.object({
path: z.string().min(1),
path: binaryTargetPathSchema,
format: executableFormatHintSchema
.optional()
.describe(

View File

@ -1,11 +1,16 @@
import { describe, expect, it } from "vitest";
import { browserScenarioBrowserSchema } from "../domain/browserScenarioValues.js";
import { firmwareInputSchemas } from "../domain/firmware/firmwareAnalysis.js";
import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js";
import { managedArtifactInputSchema } from "./managed/managedToolContracts.js";
import { exportEvidenceBundleInputSchema } from "./sessionToolContracts.js";
import {
closeBinaryInputSchema,
openBinaryInputSchema,
} from "./sessionLifecycleInputs.js";
import { importEvidenceBundleInputSchema } from "./sessionToolSchemas.js";
import { binarySessionInputSchema } from "./sessionStatusContract.js";
const openBinaryAbsolute = (snapshot_path: string) =>
openBinaryInputSchema.safeParse({ path: "/tmp/fixture", snapshot_path });
@ -119,3 +124,151 @@ describe.runIf(process.platform === "win32")(
});
},
);
describe("open binary target path requires an absolute local path", () => {
it("rejects a relative target path", () => {
expect(
openBinaryInputSchema.safeParse({ path: "fixtures/app.bin" }).success,
).toBe(false);
});
it("accepts an absolute target path", () => {
expect(
openBinaryInputSchema.safeParse({ path: "/tmp/fixture.bin" }).success,
).toBe(true);
});
});
describe("evidence bundle import path requires an absolute local path", () => {
it("rejects a relative bundle path", () => {
expect(
importEvidenceBundleInputSchema.safeParse({ path: "evidence.json" })
.success,
).toBe(false);
});
it("accepts an absolute bundle path", () => {
expect(
importEvidenceBundleInputSchema.safeParse({ path: "/tmp/evidence.json" })
.success,
).toBe(true);
});
});
describe("firmware input path requires an absolute local path", () => {
it("rejects a relative firmware path when inspecting regions", () => {
expect(
firmwareInputSchemas.inspect_firmware_regions.safeParse({
path: "firmware.bin",
}).success,
).toBe(false);
});
it("rejects a relative firmware path when extracting", () => {
expect(
firmwareInputSchemas.extract_firmware.safeParse({
path: "firmware.bin",
output_directory: "/tmp/firmware-output",
}).success,
).toBe(false);
});
it("accepts an absolute firmware path", () => {
expect(
firmwareInputSchemas.inspect_firmware_regions.safeParse({
path: "/tmp/firmware.bin",
}).success,
).toBe(true);
});
});
describe("browser executable path requires an absolute local path", () => {
it("rejects a bare executable name", () => {
expect(
browserScenarioBrowserSchema.safeParse({
mode: "launch",
executable_path: "chrome",
}).success,
).toBe(false);
});
it("accepts an absolute executable path", () => {
expect(
browserScenarioBrowserSchema.safeParse({
mode: "launch",
executable_path: "/opt/chromium/chrome",
}).success,
).toBe(true);
});
});
describe("electron observation paths require absolute local paths", () => {
const valid = {
executable_path: "/Applications/Electron.app/Contents/MacOS/Electron",
application_path: "/tmp/electron-app/main.js",
};
it("rejects a relative executable path", () => {
expect(
electronActiveObservationInputSchema.safeParse({
...valid,
executable_path: "Electron",
}).success,
).toBe(false);
});
it("rejects a relative application path", () => {
expect(
electronActiveObservationInputSchema.safeParse({
...valid,
application_path: "main.js",
}).success,
).toBe(false);
});
it("rejects a relative application root", () => {
expect(
electronActiveObservationInputSchema.safeParse({
...valid,
application_root: "app",
}).success,
).toBe(false);
});
it("accepts absolute observation paths", () => {
expect(
electronActiveObservationInputSchema.safeParse({
...valid,
application_root: "/tmp/electron-app",
}).success,
).toBe(true);
});
it("still accepts omitting the application root", () => {
expect(electronActiveObservationInputSchema.safeParse(valid).success).toBe(
true,
);
});
});
describe("expected server path requires an absolute local path", () => {
it("rejects a relative server path", () => {
expect(
binarySessionInputSchema.safeParse({
expected_server_path: "dist/main.js",
}).success,
).toBe(false);
});
it("accepts an absolute server path", () => {
expect(
binarySessionInputSchema.safeParse({
expected_server_path: "/opt/rea/dist/main.js",
}).success,
).toBe(true);
});
it("still accepts omitting the server path", () => {
expect(binarySessionInputSchema.safeParse({}).success).toBe(true);
});
});

View File

@ -1,5 +1,7 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
/** Optional expectations used to compare the live session with its caller. */
export const binarySessionInputSchema = z.object({
expected_package_version: z.string().min(1).optional(),
@ -7,5 +9,15 @@ export const binarySessionInputSchema = z.object({
.string()
.regex(/^[a-f0-9]{64}$/u)
.optional(),
expected_server_path: z.string().min(1).optional(),
expected_server_path: z
.string()
.min(1)
.refine(isAbsoluteLocalPath, {
message:
"expected_server_path must be an absolute local filesystem path (for example /opt/rea/dist/main.js)",
})
.describe(
"Absolute local filesystem path expected for the running server; relative paths are rejected.",
)
.optional(),
});

View File

@ -1,5 +1,7 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
import { artifactComparisonInputSchema } from "../domain/artifactComparison.js";
import { bundleComparisonInputSchema } from "../domain/bundleComparison.js";
import { callPathInputSchema } from "../domain/callPath.js";
@ -36,7 +38,16 @@ export const addressContextInputSchema = z.strictObject({
/** Session-owned Evidence bundle import options. */
export const importEvidenceBundleInputSchema = z.strictObject({
path: z.string().min(1),
path: z
.string()
.min(1)
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/evidence.json or C:\\rea\\evidence.json)",
})
.describe(
"Absolute local filesystem path for the evidence bundle to import; relative paths are rejected.",
),
});
/** Evidence references for deterministic process comparison. */

View File

@ -55,7 +55,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
open_binary: { path: "/tmp/fixture" },
export_evidence_bundle: { path: "/tmp/evidence.json" },
inspect_address_context: { address: "0x1000" },
import_evidence_bundle: { path: "evidence.json" },
import_evidence_bundle: { path: "/tmp/evidence.json" },
capture_process_scenario: {
executable: "/usr/bin/true",
working_directory: "/tmp",

View File

@ -56,7 +56,7 @@ it("does not require duplicate origin scope declarations", () => {
const scenario = browserScenarioSchema.parse({
browser: {
mode: "launch",
executable_path: "chrome",
executable_path: "/opt/chromium/chrome",
},
start_url: { url: "https://app.example.test/" },
actions: [

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "./localPath.js";
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
import {
@ -11,7 +12,14 @@ export const scenarioIdentifierSchema = z
.string()
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const browserExecutablePathSchema = z.string().trim().min(1);
const browserExecutablePathSchema = z
.string()
.trim()
.min(1)
.refine(isAbsoluteLocalPath, {
message:
"executable_path must be an absolute local filesystem path (for example /opt/chromium/chrome or C:\\chromium\\chrome.exe)",
});
const browserScenarioBaseUrlSchema = z
.string()
@ -67,7 +75,9 @@ export type BrowserScenarioUrl = z.infer<typeof browserScenarioUrlSchema>;
export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [
z.strictObject({
mode: z.literal("launch"),
executable_path: browserExecutablePathSchema,
executable_path: browserExecutablePathSchema.describe(
"Absolute local filesystem path for the browser executable; relative paths are rejected.",
),
headless: z
.boolean()
.default(true)

View File

@ -1,11 +1,21 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { jsonValueSchema } from "../jsonValue.js";
import { isAbsoluteLocalPath } 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: z.string().min(1).describe("Local firmware file; never executed"),
path: z
.string()
.min(1)
.refine(isAbsoluteLocalPath, {
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.",
),
};
/** Explicit firmware inspection and extraction intent, independent of engines. */

View File

@ -1,6 +1,11 @@
import { z } from "zod";
const pathInputSchema = z.string().trim().min(1);
import { isAbsoluteLocalPath } from "../localPath.js";
const pathInputSchema = z.string().trim().min(1).refine(isAbsoluteLocalPath, {
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)
@ -60,9 +65,17 @@ const actionSchema = z.discriminatedUnion("kind", [
/** Input for one explicit, provider-owned Electron runtime experiment. */
export const electronActiveObservationInputSchema = z.strictObject({
executable_path: pathInputSchema,
application_path: pathInputSchema,
application_root: pathInputSchema.optional(),
executable_path: pathInputSchema.describe(
"Absolute local filesystem path for the Electron executable; relative paths are rejected.",
),
application_path: pathInputSchema.describe(
"Absolute local filesystem path for the Electron application; relative paths are rejected.",
),
application_root: pathInputSchema
.optional()
.describe(
"Absolute local filesystem root for application-relative paths; omit to derive it from the application path. Relative paths are rejected.",
),
args: z.array(z.string()).default([]),
actions: z.array(actionSchema).default([]),
});

View File

@ -89,6 +89,24 @@ cliTest.skipIf(process.platform !== "linux")(
},
);
cliTest.skipIf(process.platform !== "linux")(
"resolves relative firmware CLI paths against the operator working directory",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-firmware-cli-relative-");
await writeFile(join(root, "input.bin"), "firmware fixture");
const cliResult = await cli.run({
arguments: ["inspect-firmware-regions", "input.bin", "--json"],
cwd: root,
environment: { REA_LOG_LEVEL: "silent" },
timeoutMs: 10_000,
});
// A relative path must pass input validation and reach the provider,
// which reports the missing firmware engine — not a validation error.
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject({ code: "capability_unavailable" });
},
);
cliTest(
"preserves optional JavaScript recovery configuration through CLI and MCP",
async ({ cli }) => {

View File

@ -1,9 +1,12 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isAbsolute, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parseCliJsonInput } from "../../../src/cliJsonInput.js";
import {
parseCliJsonInput,
resolveCliJsonPaths,
} from "../../../src/cliJsonInput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("CLI JSON input", () => {
@ -51,3 +54,41 @@ describe("CLI JSON input", () => {
});
});
});
describe("resolveCliJsonPaths", () => {
it("resolves named relative fields against the operator working directory", () => {
expect(
resolveCliJsonPaths({ executable_path: "chrome" }, [["executable_path"]]),
).toEqual({ executable_path: resolve("chrome") });
expect(isAbsolute(resolve("chrome"))).toBe(true);
});
it("leaves absolute fields unchanged", () => {
const value = { path: "/tmp/evidence.json" };
expect(resolveCliJsonPaths(value, [["path"]])).toEqual(value);
});
it("resolves nested key paths and preserves sibling fields", () => {
expect(
resolveCliJsonPaths(
{
browser: { mode: "launch", executable_path: "chrome" },
actions: [],
},
[["browser", "executable_path"]],
),
).toEqual({
browser: { mode: "launch", executable_path: resolve("chrome") },
actions: [],
});
});
it("passes through non-objects, missing keys, and non-string fields", () => {
expect(resolveCliJsonPaths(null, [["path"]])).toBe(null);
expect(resolveCliJsonPaths("text", [["path"]])).toBe("text");
expect(resolveCliJsonPaths({ other: 1 }, [["path"]])).toEqual({
other: 1,
});
expect(resolveCliJsonPaths({ path: 42 }, [["path"]])).toEqual({ path: 42 });
});
});