fix(native): preserve literal environment map members

This commit is contained in:
morluto committed 2026-10-24 03:00:07 +08:00
1 parent 0f5ece1d58
commit b413332bd8
3 files changed
+68 -10

No files matched your search

+1 -1
View File
@@ -730,7 +730,7 @@ cancellation Evidence, even if the target has already exited.
while excess bytes are drained and counted without growing capture files).
Each stream reports `complete`; when draining could not finish, its byte
count is an observed lower bound and truncation is explicit;
- caller-selected arguments, working directory, and environment overrides;
- caller-selected arguments, working directory, and environment overrides, including literal JSON environment names such as `__proto__`;
- `selected_file_sha256` and LLDB module identity observations. These checks do
not pin the executable against concurrent mutation; `loaded_image_sha256`
remains unknown;
+34 -9
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { jsonObjectSchema } from "../jsonValue.js";
import { localPathStringSchema } from "../localPath.js";
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
@@ -58,6 +59,37 @@ export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
}),
]);
const environmentNameSchema = z
.string()
.min(1)
.regex(/^[^=\x00]+$/u, "Environment names cannot contain '=' or NUL");
const environmentValueSchema = z
.string()
.regex(/^[^\x00]*$/u, "Environment values cannot contain NUL");
const environmentRecordProjection = z.record(
environmentNameSchema,
environmentValueSchema,
);
// z.record skips __proto__. Own the JSON map before validating entries so every
// selected environment name survives input parsing, readback and partial Evidence.
const nativeCallEnvironmentSchema = jsonObjectSchema
.superRefine((value, context) => {
for (const [name, entry] of Object.entries(value)) {
for (const parsed of [
environmentNameSchema.safeParse(name),
environmentValueSchema.safeParse(entry),
]) {
if (!parsed.success)
for (const issue of parsed.error.issues)
context.addIssue({ ...issue, path: [name, ...issue.path] });
}
}
})
.transform((value) => value as Record<string, string>);
nativeCallEnvironmentSchema._zod.toJSONSchema = () =>
z.toJSONSchema(environmentRecordProjection, { io: "input" });
/**
* Launch the active Mach-O as an owned process under LLDB and observe calls.
* Duration and event bounds keep runtime observation finite.
@@ -65,14 +97,7 @@ export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
export const nativeCallObservationInputSchema = z.strictObject({
breakpoints: z.array(nativeCallBreakpointSchema).min(1),
arguments: z.array(nativeString).default([]),
environment: z
.record(
z
.string()
.min(1)
.regex(/^[^=\x00]+$/u, "Environment names cannot contain '=' or NUL"),
z.string().regex(/^[^\x00]*$/u, "Environment values cannot contain NUL"),
)
environment: nativeCallEnvironmentSchema
.default({})
.describe("Overrides on top of the environment REA runs with."),
working_directory: localPathStringSchema.optional(),
@@ -152,7 +177,7 @@ export const nativeCallObservationResultSchema = z.strictObject({
sha256: z.string().regex(/^[0-9a-f]{64}$/u),
architecture: z.string(),
arguments: z.array(z.string()),
environment: z.record(z.string(), z.string()),
environment: nativeCallEnvironmentSchema,
working_directory: z.string().nullable(),
launch_identity: z.strictObject({
/** A pathname hash does not verify the bytes mapped into the process. */
@@ -32,6 +32,12 @@ const tracer: NativeCallTracer & { calls: number } = {
calls: 0,
trace(request) {
this.calls++;
expect(request.input.environment).toEqual(
JSON.parse(
'{"__proto__":"selected literal value","constructor":"ordinary value"}',
),
);
expect(Object.hasOwn(request.input.environment, "__proto__")).toBe(true);
return Promise.resolve(
ok({
debugger: {
@@ -113,6 +119,9 @@ it("routes observe_native_calls through MCP with schema-checked input and output
arguments: {
breakpoints: [{ kind: "function", name: "open" }],
duration_ms: 1000,
environment: JSON.parse(
'{"__proto__":"selected literal value","constructor":"ordinary value"}',
),
},
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
@@ -124,11 +133,35 @@ it("routes observe_native_calls through MCP with schema-checked input and output
.parse(called.structuredContent).normalized_result,
);
expect(result.process).toMatchObject({ pid: 99, outcome: "exited" });
expect(result.target.environment).toEqual(
JSON.parse(
'{"__proto__":"selected literal value","constructor":"ordinary value"}',
),
);
expect(Object.hasOwn(result.target.environment, "__proto__")).toBe(true);
expect(result.breakpoints[0]?.request).toEqual({
kind: "function",
name: "open",
module: null,
});
const invalidEnvironment = await client.callTool({
name: "observe_native_calls",
arguments: {
breakpoints: [{ kind: "function", name: "open" }],
environment: JSON.parse('{"__proto__":7}'),
},
});
expect(parseMcpToolError(invalidEnvironment).error).toMatchObject({
code: "invalid_request",
details: {
issues: [
expect.objectContaining({
path: ["environment", "__proto__"],
reason: "invalid_type",
}),
],
},
});
const rejected = await client.callTool({
name: "observe_native_calls",
arguments: {