mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(json): preserve configuration and capture data at admission
This commit is contained in:
1 parent
9085222ca1
commit
4ca3032e7f
12 files changed
+373
-25
No files matched your search
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseClientRegistration } from "./ClientRegistration.js";
|
||||||
|
import type { SetupClient } from "./SupportedClients.js";
|
||||||
|
|
||||||
|
const client: SetupClient = {
|
||||||
|
name: "opencode",
|
||||||
|
format: "opencode",
|
||||||
|
configPath: "/fixture/opencode.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("preserves every OpenCode environment key while normalizing its launcher", () => {
|
||||||
|
const input = JSON.parse(
|
||||||
|
'{"type":"local","command":["rea","mcp"],"environment":{"__proto__":"operator","constructor":"selected","normal":"value"}}',
|
||||||
|
);
|
||||||
|
const result = parseClientRegistration(input, client);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
expect(result.value.command).toBe("rea");
|
||||||
|
expect(result.value.args).toEqual(["mcp"]);
|
||||||
|
expect(result.value.environment).toEqual(input.environment);
|
||||||
|
const environment = result.value.environment;
|
||||||
|
if (typeof environment !== "object" || environment === null)
|
||||||
|
throw new Error("Missing parsed environment");
|
||||||
|
expect(Object.hasOwn(environment, "__proto__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([42, null, {}])(
|
||||||
|
"rejects malformed OpenCode environment values at __proto__: %j",
|
||||||
|
(value) => {
|
||||||
|
const environment = Object.fromEntries([["__proto__", value]]);
|
||||||
|
const result = parseClientRegistration(
|
||||||
|
{ type: "local", command: ["rea", "mcp"], environment },
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { jsonRecordSchema } from "../domain/jsonRecord.js";
|
||||||
|
|
||||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
@@ -141,7 +142,7 @@ export const parseClientRegistration = (
|
|||||||
if (client.format === "opencode") {
|
if (client.format === "opencode") {
|
||||||
const entry = opencodeLauncherSchema
|
const entry = opencodeLauncherSchema
|
||||||
.extend({
|
.extend({
|
||||||
environment: z.record(z.string(), z.string()).optional(),
|
environment: jsonRecordSchema(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.transform(normalizeOpencodeLauncher)
|
.transform(normalizeOpencodeLauncher)
|
||||||
.safeParse(value);
|
.safeParse(value);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it, onTestFinished } from "vitest";
|
||||||
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||||
|
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||||
|
import { historicalHar } from "../../../tests/fixtures/historicalHar.js";
|
||||||
|
import { decodeHarCapture } from "./HarCapture.js";
|
||||||
|
import { readHistoricalCaptureReply } from "./HistoricalCaptureReply.js";
|
||||||
|
|
||||||
|
const replyFile = async (bytes: Uint8Array) => {
|
||||||
|
const root = await createTestTempDirectory("rea-capture-reply-utf8-");
|
||||||
|
const path = join(root, "reply.json");
|
||||||
|
await writeFile(path, bytes);
|
||||||
|
const resources = new ArtifactResourceScope();
|
||||||
|
onTestFinished(() => resources.close());
|
||||||
|
return { path, resources };
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyText = (kind: "error" | "output", text: string) => {
|
||||||
|
if (kind === "error")
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
reason: "format",
|
||||||
|
message: text,
|
||||||
|
pointer: "",
|
||||||
|
});
|
||||||
|
const capture = historicalHar();
|
||||||
|
capture.log.entries[0]!.response.content.text = text;
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
value: decodeHarCapture(JSON.stringify(capture), []),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(["error", "output"] as const)(
|
||||||
|
"rejects malformed bytes inside an otherwise schema-valid %s reply",
|
||||||
|
async (kind) => {
|
||||||
|
const text = replyText(kind, "WIRE_MARKER");
|
||||||
|
const offset = text.indexOf("WIRE_MARKER");
|
||||||
|
expect(offset).toBeGreaterThan(0);
|
||||||
|
const bytes = Buffer.concat([
|
||||||
|
Buffer.from(text.slice(0, offset)),
|
||||||
|
Buffer.from([0xff]),
|
||||||
|
Buffer.from(text.slice(offset + "WIRE_MARKER".length)),
|
||||||
|
]);
|
||||||
|
const file = await replyFile(bytes);
|
||||||
|
await expect(
|
||||||
|
readHistoricalCaptureReply(file.path, "/capture.har", file.resources),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisOutputError",
|
||||||
|
reason: expect.stringContaining("Reply is not valid UTF-8."),
|
||||||
|
cause: { code: "ERR_ENCODING_INVALID_ENCODED_DATA" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["error", "output"] as const)(
|
||||||
|
"preserves valid replacement-character text and exact byte counts in %s replies",
|
||||||
|
async (kind) => {
|
||||||
|
const text = replyText(kind, "valid � 雪");
|
||||||
|
const bytes = Buffer.from(text);
|
||||||
|
const file = await replyFile(bytes);
|
||||||
|
const result = await readHistoricalCaptureReply(
|
||||||
|
file.path,
|
||||||
|
"/capture.har",
|
||||||
|
file.resources,
|
||||||
|
);
|
||||||
|
expect(result.bytes).toBe(bytes.length);
|
||||||
|
expect(result.finalization).toBeUndefined();
|
||||||
|
expect(result.value).toEqual(JSON.parse(text));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["{broken", "\uFEFF{}"])(
|
||||||
|
"retains malformed JSON handling for %j",
|
||||||
|
async (text) => {
|
||||||
|
const file = await replyFile(Buffer.from(text));
|
||||||
|
await expect(
|
||||||
|
readHistoricalCaptureReply(file.path, "/capture.har", file.resources),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisOutputError",
|
||||||
|
reason: expect.stringContaining("Reply is malformed JSON."),
|
||||||
|
cause: expect.any(SyntaxError),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { safeParseUtf8Json } from "../../domain/safeJson.js";
|
||||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||||
import {
|
import {
|
||||||
@@ -57,8 +58,10 @@ export const readHistoricalCaptureReply = async (
|
|||||||
finalization = cause;
|
finalization = cause;
|
||||||
file = completed;
|
file = completed;
|
||||||
}
|
}
|
||||||
|
const decoded = safeParseUtf8Json(file.bytes);
|
||||||
|
if (!decoded.ok) throw decoded.cause;
|
||||||
return {
|
return {
|
||||||
value: replySchema.parse(JSON.parse(file.bytes.toString("utf8"))),
|
value: replySchema.parse(decoded.value),
|
||||||
bytes: file.bytes.length,
|
bytes: file.bytes.length,
|
||||||
finalization,
|
finalization,
|
||||||
};
|
};
|
||||||
@@ -87,6 +90,12 @@ const replyFailureReason = (cause: unknown): string => {
|
|||||||
if (cause instanceof z.ZodError)
|
if (cause instanceof z.ZodError)
|
||||||
return `Reply violates its schema at ${cause.issues[0]?.path.map(String).join(".") ?? "root"}.`;
|
return `Reply violates its schema at ${cause.issues[0]?.path.map(String).join(".") ?? "root"}.`;
|
||||||
if (cause instanceof SyntaxError) return "Reply is malformed JSON.";
|
if (cause instanceof SyntaxError) return "Reply is malformed JSON.";
|
||||||
|
if (
|
||||||
|
cause instanceof Error &&
|
||||||
|
"code" in cause &&
|
||||||
|
cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA"
|
||||||
|
)
|
||||||
|
return "Reply is not valid UTF-8.";
|
||||||
if (cause instanceof Error && "code" in cause)
|
if (cause instanceof Error && "code" in cause)
|
||||||
return `Reply file could not be read (${String(cause.code)}).`;
|
return `Reply file could not be read (${String(cause.code)}).`;
|
||||||
return "Reply could not be validated.";
|
return "Reply could not be validated.";
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
|
|||||||
#scenarioTransactions = 0;
|
#scenarioTransactions = 0;
|
||||||
#scenarioWithoutBody = 0;
|
#scenarioWithoutBody = 0;
|
||||||
#scenarioRedactedBodies = 0;
|
#scenarioRedactedBodies = 0;
|
||||||
#unrepresentableBodyKey = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
|
maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
|
||||||
@@ -335,11 +334,8 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
|
|||||||
const root = parseJsonObject(body.text);
|
const root = parseJsonObject(body.text);
|
||||||
if (root === null) return ok(null);
|
if (root === null) return ok(null);
|
||||||
const entries: [string, JsonTypeName][] = [];
|
const entries: [string, JsonTypeName][] = [];
|
||||||
for (const [name, value] of Object.entries(root)) {
|
for (const [name, value] of Object.entries(root))
|
||||||
// JSON parsers and schema clones drop a __proto__ own property; omit it explicitly.
|
entries.push([name, jsonValueType(value)]);
|
||||||
if (name === "__proto__") this.#unrepresentableBodyKey = true;
|
|
||||||
else entries.push([name, jsonValueType(value)]);
|
|
||||||
}
|
|
||||||
return ok({ type: "object", properties: Object.fromEntries(entries) });
|
return ok({ type: "object", properties: Object.fromEntries(entries) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,11 +543,6 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
|
|||||||
"Body shapes list top-level JSON key types from one recorded request each; they do not establish required fields or a complete schema.",
|
"Body shapes list top-level JSON key types from one recorded request each; they do not establish required fields or a complete schema.",
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(this.#unrepresentableBodyKey
|
|
||||||
? [
|
|
||||||
"A top-level __proto__ key in a recorded JSON body is omitted from body_shape; it cannot be an own property of the published object.",
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...this.#segmentLimitLimitations(toolGroups),
|
...this.#segmentLimitLimitations(toolGroups),
|
||||||
...(this.#scenarioWithoutBody > 0
|
...(this.#scenarioWithoutBody > 0
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ it("refuses a path whose templated representation exceeds projection capacity",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits a __proto__ body key explicitly instead of publishing a prototype", () => {
|
it("preserves every own body key while publishing only its observed type", () => {
|
||||||
const projected = manifest(
|
const projected = manifest(
|
||||||
[],
|
[],
|
||||||
syntheticCapture([
|
syntheticCapture([
|
||||||
@@ -903,12 +903,14 @@ it("omits a __proto__ body key explicitly instead of publishing a prototype", ()
|
|||||||
const shape = projected.tools[0]?.body_shape;
|
const shape = projected.tools[0]?.body_shape;
|
||||||
expect(shape).toEqual({
|
expect(shape).toEqual({
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { constructor: "string", name: "number" },
|
properties: {
|
||||||
|
["__proto__"]: "object",
|
||||||
|
constructor: "string",
|
||||||
|
name: "number",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(Object.getPrototypeOf(shape?.properties)).toBe(Object.prototype);
|
expect(Object.getPrototypeOf(shape?.properties)).toBe(Object.prototype);
|
||||||
expect(projected.limitations).toContain(
|
expect(Object.hasOwn(shape!.properties, "__proto__")).toBe(true);
|
||||||
"A top-level __proto__ key in a recorded JSON body is omitted from body_shape; it cannot be an own property of the published object.",
|
|
||||||
);
|
|
||||||
expect(JSON.stringify(projected)).not.toContain("admin");
|
expect(JSON.stringify(projected)).not.toContain("admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { jsonRecordSchema } from "../jsonRecord.js";
|
||||||
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
||||||
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||||
@@ -72,7 +73,7 @@ const toolSchema = z.strictObject({
|
|||||||
body_shape: z
|
body_shape: z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
type: z.literal("object"),
|
type: z.literal("object"),
|
||||||
properties: z.record(z.string(), jsonTypeNameSchema),
|
properties: jsonRecordSchema(jsonTypeNameSchema),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
statuses: z.array(z.number()),
|
statuses: z.array(z.number()),
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { jsonRecordSchema } from "./jsonRecord.js";
|
||||||
|
import type { JsonValue } from "./jsonValue.js";
|
||||||
|
|
||||||
|
it("owns every observed key while retaining normalized values", () => {
|
||||||
|
const source: unknown = JSON.parse(
|
||||||
|
'{"__proto__":"observed","constructor":"source","ordinary":"value"}',
|
||||||
|
);
|
||||||
|
const schema = jsonRecordSchema(
|
||||||
|
z.string().transform((value) => value.toUpperCase()),
|
||||||
|
);
|
||||||
|
const parsed = schema.parse(source);
|
||||||
|
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
|
||||||
|
expect(JSON.stringify(parsed)).toBe(
|
||||||
|
'{"__proto__":"OBSERVED","constructor":"SOURCE","ordinary":"VALUE"}',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(source)).toBe(
|
||||||
|
'{"__proto__":"observed","constructor":"source","ordinary":"value"}',
|
||||||
|
);
|
||||||
|
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes value failures with every original key and nested value path", () => {
|
||||||
|
const schema = jsonRecordSchema(z.strictObject({ name: z.string() }));
|
||||||
|
const source: unknown = JSON.parse(
|
||||||
|
'{"__proto__":{"name":7},"ordinary":{"name":false}}',
|
||||||
|
);
|
||||||
|
const parsed = schema.safeParse(source);
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
if (parsed.success) throw new Error("Expected value validation failure");
|
||||||
|
expect(parsed.error.issues.map(({ path }) => path)).toEqual([
|
||||||
|
["__proto__", "name"],
|
||||||
|
["ordinary", "name"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains defaults produced within JSON entries without mutating their source", () => {
|
||||||
|
const source: unknown = JSON.parse(
|
||||||
|
'{"__proto__":{},"ordinary":{"name":"given"}}',
|
||||||
|
);
|
||||||
|
const schema = jsonRecordSchema(
|
||||||
|
z.strictObject({ name: z.string().default("default") }),
|
||||||
|
);
|
||||||
|
const parsed = schema.parse(source);
|
||||||
|
expect(JSON.stringify(parsed)).toBe(
|
||||||
|
'{"__proto__":{"name":"default"},"ordinary":{"name":"given"}}',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(source)).toBe(
|
||||||
|
'{"__proto__":{},"ordinary":{"name":"given"}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the canonical JSON input boundary before per-entry defaults", () => {
|
||||||
|
const schema = jsonRecordSchema(z.string().default("default"));
|
||||||
|
expect(schema.safeParse({ ordinary: undefined }).success).toBe(false);
|
||||||
|
expect(schema.safeParse({ ordinary: Infinity }).success).toBe(false);
|
||||||
|
expect(schema.safeParse([]).success).toBe(false);
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["draft-04", "draft-07", "draft-2020-12", "openapi-3.0"] as const)(
|
||||||
|
"exports the same typed-record schema in every IO mode for %s",
|
||||||
|
(target) => {
|
||||||
|
const values: z.ZodType<JsonValue>[] = [
|
||||||
|
z.string(),
|
||||||
|
z.enum(["unknown", "object", "array"]),
|
||||||
|
z.strictObject({ name: z.string().default("default") }),
|
||||||
|
];
|
||||||
|
for (const value of values)
|
||||||
|
for (const io of ["input", "output"] as const) {
|
||||||
|
const actual = z.strictObject({ entries: jsonRecordSchema(value) });
|
||||||
|
const expected = z.strictObject({
|
||||||
|
entries: z.record(z.string(), value),
|
||||||
|
});
|
||||||
|
expect(z.toJSONSchema(actual, { target, io })).toEqual(
|
||||||
|
z.toJSONSchema(expected, { target, io }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("preserves input schema projection for a normalizing transform", () => {
|
||||||
|
const value = z.string().transform((entry) => entry.toUpperCase());
|
||||||
|
expect(z.toJSONSchema(jsonRecordSchema(value), { io: "input" })).toEqual(
|
||||||
|
z.toJSONSchema(z.record(z.string(), value), { io: "input" }),
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
z.toJSONSchema(jsonRecordSchema(value), { io: "output" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["draft-04", "draft-07", "draft-2020-12", "openapi-3.0"] as const)(
|
||||||
|
"preserves shared schema references through Standard Schema export for %s",
|
||||||
|
(target) => {
|
||||||
|
const value = z.strictObject({ name: z.string().default("default") });
|
||||||
|
const map = jsonRecordSchema(value);
|
||||||
|
const record = z.record(z.string(), value);
|
||||||
|
const actual = z.strictObject({ left: map, right: map, entry: value });
|
||||||
|
const expected = z.strictObject({
|
||||||
|
left: record,
|
||||||
|
right: record,
|
||||||
|
entry: value,
|
||||||
|
});
|
||||||
|
for (const io of ["input", "output"] as const)
|
||||||
|
for (const reused of ["inline", "ref"] as const) {
|
||||||
|
expect(z.toJSONSchema(actual, { target, io, reused })).toEqual(
|
||||||
|
z.toJSONSchema(expected, { target, io, reused }),
|
||||||
|
);
|
||||||
|
const options = { target, libraryOptions: { reused } };
|
||||||
|
expect(actual["~standard"].jsonSchema[io](options)).toEqual(
|
||||||
|
expected["~standard"].jsonSchema[io](options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { jsonObjectSchema, type JsonValue } from "./jsonValue.js";
|
||||||
|
|
||||||
|
/** Own every JSON map key, then validate and retain each value's normalized facts. */
|
||||||
|
export const jsonRecordSchema = <Value extends JsonValue>(
|
||||||
|
valueSchema: z.ZodType<Value>,
|
||||||
|
) => {
|
||||||
|
const projection = z.record(z.string(), valueSchema);
|
||||||
|
const schema = jsonObjectSchema.transform(
|
||||||
|
(value, context): Record<string, Value> => {
|
||||||
|
const entries: [string, Value][] = [];
|
||||||
|
let invalid = false;
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
const parsed = valueSchema.safeParse(entry);
|
||||||
|
if (parsed.success) entries.push([key, parsed.data]);
|
||||||
|
else {
|
||||||
|
invalid = true;
|
||||||
|
for (const issue of parsed.error.issues)
|
||||||
|
context.addIssue({ ...issue, path: [key, ...issue.path] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invalid ? z.NEVER : Object.fromEntries(entries);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Delegate to Zod's own record projection with the caller's dialect, IO and
|
||||||
|
// reference context; a context-free JSON override would lose those choices.
|
||||||
|
schema._zod.processJSONSchema = (context, json, params) =>
|
||||||
|
projection._zod.processJSONSchema!(context, json, params);
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { rm, writeFile } from "node:fs/promises";
|
import { rm, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { vi } from "vitest";
|
|
||||||
import { createIdaTarget } from "../../tests/fixtures/idaMcp.js";
|
import { createIdaTarget } from "../../tests/fixtures/idaMcp.js";
|
||||||
import { readIdaConfiguration } from "./IdaConfiguration.js";
|
import { readIdaConfiguration } from "./IdaConfiguration.js";
|
||||||
import { decodeIdaToolResult } from "./IdaMcpConnection.js";
|
import { decodeIdaToolResult } from "./IdaMcpConnection.js";
|
||||||
@@ -249,3 +248,54 @@ describe("IDA MCP result decoding", () => {
|
|||||||
expect(() => decodeIdaToolResult({ content: "invalid" })).toThrow();
|
expect(() => decodeIdaToolResult({ content: "invalid" })).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["env", "headers"] as const)(
|
||||||
|
"preserves all explicit IDA %s keys",
|
||||||
|
async (field) => {
|
||||||
|
const { root } = await createIdaTarget();
|
||||||
|
roots.push(root);
|
||||||
|
const path = join(root, "mcp.json");
|
||||||
|
const mapping = JSON.parse(
|
||||||
|
'{"__proto__":"operator","constructor":"selected","normal":"value"}',
|
||||||
|
);
|
||||||
|
const transport =
|
||||||
|
field === "env"
|
||||||
|
? { command: "python" }
|
||||||
|
: { url: "http://127.0.0.1:8745/mcp" };
|
||||||
|
await writeFile(path, JSON.stringify({ ...transport, [field]: mapping }));
|
||||||
|
const result = await read(path);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const parsed =
|
||||||
|
field === "env" && "env" in result.value
|
||||||
|
? result.value.env
|
||||||
|
: "headers" in result.value
|
||||||
|
? result.value.headers
|
||||||
|
: undefined;
|
||||||
|
expect(parsed).toEqual(mapping);
|
||||||
|
expect(Object.hasOwn(parsed ?? {}, "__proto__")).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["env", "headers"] as const)(
|
||||||
|
"rejects malformed explicit IDA %s values at __proto__",
|
||||||
|
async (field) => {
|
||||||
|
const { root } = await createIdaTarget();
|
||||||
|
roots.push(root);
|
||||||
|
const path = join(root, "mcp.json");
|
||||||
|
const transport =
|
||||||
|
field === "env"
|
||||||
|
? { command: "python" }
|
||||||
|
: { url: "http://127.0.0.1:8745/mcp" };
|
||||||
|
for (const value of [42, null, {}]) {
|
||||||
|
await writeFile(
|
||||||
|
path,
|
||||||
|
JSON.stringify({
|
||||||
|
...transport,
|
||||||
|
[field]: Object.fromEntries([["__proto__", value]]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await read(path)).ok).toBe(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isAbsolute } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { jsonRecordSchema } from "../domain/jsonRecord.js";
|
||||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||||
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
|
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
|
||||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||||
@@ -48,7 +49,7 @@ const registrationSchema = z
|
|||||||
type: z.literal("stdio").optional(),
|
type: z.literal("stdio").optional(),
|
||||||
command: z.string().min(1),
|
command: z.string().min(1),
|
||||||
args: z.array(z.string()).default([]),
|
args: z.array(z.string()).default([]),
|
||||||
env: z.record(z.string(), z.string()).default({}),
|
env: jsonRecordSchema(z.string()).default({}),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
z
|
z
|
||||||
@@ -64,7 +65,7 @@ const registrationSchema = z
|
|||||||
url.password === ""
|
url.password === ""
|
||||||
);
|
);
|
||||||
}, "IDA MCP must use a local HTTP endpoint without URL credentials"),
|
}, "IDA MCP must use a local HTTP endpoint without URL credentials"),
|
||||||
headers: z.record(z.string(), z.string()).default({}),
|
headers: jsonRecordSchema(z.string()).default({}),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1517,7 +1517,14 @@ it("projects an environment manifest identically from retained, portable and fil
|
|||||||
"inspect_analysis_view",
|
"inspect_analysis_view",
|
||||||
schemaValidation.assertions,
|
schemaValidation.assertions,
|
||||||
);
|
);
|
||||||
const capture = environmentManifestCaptureFixture();
|
const har = environmentManifestHar();
|
||||||
|
const request = har.log.entries.find(
|
||||||
|
(entry) => entry.request.postData,
|
||||||
|
)!.request;
|
||||||
|
request.postData!.text =
|
||||||
|
'{"__proto__":{"source":"recorded body value"},"constructor":"caller","name":1}';
|
||||||
|
request.bodySize = Buffer.byteLength(request.postData!.text);
|
||||||
|
const capture = environmentManifestCaptureFixture(har.log.entries);
|
||||||
const parent = analysisViewCaptureEvidence(capture);
|
const parent = analysisViewCaptureEvidence(capture);
|
||||||
expect(session.recordEvidence(parent).ok).toBe(true);
|
expect(session.recordEvidence(parent).ok).toBe(true);
|
||||||
const evidencePath = join(
|
const evidencePath = join(
|
||||||
@@ -1558,6 +1565,16 @@ it("projects an environment manifest identically from retained, portable and fil
|
|||||||
name: "grafana-example",
|
name: "grafana-example",
|
||||||
templated_groups: 13,
|
templated_groups: 13,
|
||||||
tools: expect.arrayContaining([
|
tools: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
body_shape: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
["__proto__"]: "object",
|
||||||
|
constructor: "string",
|
||||||
|
name: "number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "get_plugins_settings_by_plugin_id",
|
name: "get_plugins_settings_by_plugin_id",
|
||||||
path_template: "/api/plugins/{plugin_id}/settings",
|
path_template: "/api/plugins/{plugin_id}/settings",
|
||||||
@@ -1569,7 +1586,13 @@ it("projects an environment manifest identically from retained, portable and fil
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||||
for (const value of ["probe folder", "Main Org", "JFIF", "console.log"])
|
for (const value of [
|
||||||
|
"probe folder",
|
||||||
|
"Main Org",
|
||||||
|
"JFIF",
|
||||||
|
"console.log",
|
||||||
|
"recorded body value",
|
||||||
|
])
|
||||||
expect(JSON.stringify(response)).not.toContain(value);
|
expect(JSON.stringify(response)).not.toContain(value);
|
||||||
outputs.push(evidence.normalized_result);
|
outputs.push(evidence.normalized_result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user