From b413332bd8b9536001037ead7e222a50a6c997e7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:55:59 +0800 Subject: [PATCH] fix(native): preserve literal environment map members --- docs/native-investigation.md | 2 +- src/domain/native/nativeCallObservation.ts | 43 +++++++++++++++---- .../mcp/nativeCallObservationMcp.test.ts | 33 ++++++++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 138247c9b3..829125ad2f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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; diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index 55b95017dc..cf8d8f0b1c 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -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); +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. */ diff --git a/tests/boundary/mcp/nativeCallObservationMcp.test.ts b/tests/boundary/mcp/nativeCallObservationMcp.test.ts index 52c26c2827..863e05ed65 100644 --- a/tests/boundary/mcp/nativeCallObservationMcp.test.ts +++ b/tests/boundary/mcp/nativeCallObservationMcp.test.ts @@ -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: {