[GH-ISSUE #3338] [Bug] Process capture silently drops the accepted __proto__ environment override #1209

Open
opened 2026-10-15 18:32:01 +00:00 by bsakaguchi · 4 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3338

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

capture-process accepts an explicit environment entry named __proto__, but silently drops it before launching the selected command. The captured scenario also omits that caller-selected entry. The CLI succeeds, and the ordinary NORMAL_CAPTURE_KEY control reaches the child correctly. Launching the same child directly with this environment name works. The failure concerns preservation of a literal environment variable.

Parsing the same scenario at the scenario-schema boundary also succeeds while returning an environment with no own __proto__ key. The CLI failure reproduced on both tested Node.js versions. Other tested object-like names and the ordinary control were preserved.

Reproduction

  1. Start from a clean checkout and prepare the runtime. The original reproduction used dependencies and compiled runtime already present; these commands prepare a clean checkout for the same scenario. Process capture on macOS requires the documented native PTY backend and Apple's Swift compiler through xcrun.
git clone https://github.com/morluto/rea.git rea
cd rea
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. From the built repository root, create and run this complete scenario.
cat > environment-scenario.json <<'JSON'
{
  "executable": "node",
  "arguments": [
    "-e",
    "console.log('selected='+Object.hasOwn(process.env,'__proto__')+':'+process.env['__proto__']);console.log('control='+process.env.NORMAL_CAPTURE_KEY)"
  ],
  "environment": {
    "__proto__": "selected-value",
    "NORMAL_CAPTURE_KEY": "control-value"
  },
  "timeout_ms": 5000
}
JSON
node scripts/rea.mjs capture-process environment-scenario.json --json > environment-capture.json
node - <<'JS'
const fs = require('node:fs');
const capture = JSON.parse(fs.readFileSync('environment-capture.json', 'utf8')).normalized_result;
console.log(capture.frames.map(frame => frame.data).join(''));
console.log(JSON.stringify(capture.manifest.scenario.environment));
JS
  1. Run the direct child-process control, which confirms that the operating system and Node accept this exact key.
node - <<'JS'
const { execFileSync } = require('node:child_process');
const env = {
  ...process.env,
  ...JSON.parse('{"__proto__":"selected-value"}')
};
console.log(execFileSync(process.execPath,
  ['-e', "console.log(Object.hasOwn(process.env,'__proto__'),process.env['__proto__'])"],
  { env, encoding: 'utf8' }));
JS

The capture was executed with an isolated home and compiler module cache. These settings do not change the supplied scenario environment.

Expected behavior

An accepted explicit environment override should reach the selected command and remain in the recorded scenario commitment. docs/process-capture.md:9 says the host environment is inherited before scenario overrides apply. src/domain/process/processScenario.ts:35 defines accepted environment names, and src/domain/process/processScenario.ts:152 accepts this environment entry. AGENTS.md:13 requires preservation of caller-selected inputs and metadata.

__proto__ is a nonempty environment name without = or NUL and is not the reserved process run identifier. The child should print selected=true:selected-value, followed by control=control-value, and the recorded environment should contain both entries.

Impact and scope

Impact level P2, supported. Captures using this accepted environment key run with a different input from the one requested, and their Evidence omits the lost explicit override. The demonstrated scope is this particular key; other tested object-like names and the ordinary control were preserved.

A verified runtime workaround is available. Use /usr/bin/env as the scenario executable, put __proto__=selected-value and node before the existing arguments, and leave the ordinary control override in environment. That capture printed selected=true:selected-value and control=control-value. This preserves the target's runtime input but changes how the environment choice appears in the scenario, so it does not remove the need to preserve accepted scenario overrides.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0 and v24.21.0, macOS 27.0.1 arm64. The CLI failure reproduced on both Node.js versions.

Target and artifact context

A synthetic process-capture JSON scenario launches node -e to print a selected literal environment variable and an ordinary control. The affected artifact is the captured scenario environment in capture.manifest.scenario.environment.

Interface

CLI

Earliest failure stage

Request parsing or validation

Relevant output or evidence

The CLI succeeds, but the captured output and recorded scenario environment are:

selected=false:[object Object]
control=control-value
{"NORMAL_CAPTURE_KEY":"control-value"}

The direct child-process control prints true selected-value. The schema-boundary check also succeeds while returning an environment with no own __proto__ key. These are reproduced observations; the source references below explain the boundary at which the entry is lost.

Typed outcome or recovery expected

Run the supplied scenario and confirm both environment entries reach the child and appear in the recorded scenario. Require selected=true:selected-value and control=control-value; the ordinary control must continue to pass. Compare the direct child-process control, which already delivers the selected value. Keep the existing rejection of empty names, names containing = or NUL, and the reserved process run identifier. Preserve the literal environment entry without treating it as an object-prototype operation.

Analysis or suspected cause

Source-derived reasoning: src/application/process/ProcessCli.ts:59 parses the scenario before capture. The record parser at src/domain/process/processScenario.ts:152 accepts this input while omitting the entry, so the launch and scenario commitment receive the reduced environment. The accepted-name contract is defined at src/domain/process/processScenario.ts:35. The executed capture, its recorded environment, and the direct child-process control establish the failure; this reasoning does not claim a broader prototype-mutation vulnerability.

Additional context

  • #1036, closed as completed, covered prototype-named members at shared JSON/Evidence boundaries; this case demonstrates loss in the typed process-scenario environment record before launch and remains reproducible after that completed work.
  • #1594, closed as completed, covered binary plist dictionary-key omission and missing limitations; this case requires preserving a literal process environment override.
  • #1498, closed as completed, covered Windows PATH/Path canonicalization during executable resolution; this case drops a different accepted environment key during scenario parsing on macOS.
  • #2974 covers the Electron provider's Windows environment allowlist dropping ambient platform variables; this case uses process capture and an explicit accepted scenario override.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3338 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior `capture-process` accepts an explicit environment entry named `__proto__`, but silently drops it before launching the selected command. The captured scenario also omits that caller-selected entry. The CLI succeeds, and the ordinary `NORMAL_CAPTURE_KEY` control reaches the child correctly. Launching the same child directly with this environment name works. The failure concerns preservation of a literal environment variable. Parsing the same scenario at the scenario-schema boundary also succeeds while returning an environment with no own `__proto__` key. The CLI failure reproduced on both tested Node.js versions. Other tested object-like names and the ordinary control were preserved. ### Reproduction 1. Start from a clean checkout and prepare the runtime. The original reproduction used dependencies and compiled runtime already present; these commands prepare a clean checkout for the same scenario. Process capture on macOS requires the documented native PTY backend and Apple's Swift compiler through `xcrun`. ```sh git clone https://github.com/morluto/rea.git rea cd rea git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. From the built repository root, create and run this complete scenario. ```sh cat > environment-scenario.json <<'JSON' { "executable": "node", "arguments": [ "-e", "console.log('selected='+Object.hasOwn(process.env,'__proto__')+':'+process.env['__proto__']);console.log('control='+process.env.NORMAL_CAPTURE_KEY)" ], "environment": { "__proto__": "selected-value", "NORMAL_CAPTURE_KEY": "control-value" }, "timeout_ms": 5000 } JSON node scripts/rea.mjs capture-process environment-scenario.json --json > environment-capture.json node - <<'JS' const fs = require('node:fs'); const capture = JSON.parse(fs.readFileSync('environment-capture.json', 'utf8')).normalized_result; console.log(capture.frames.map(frame => frame.data).join('')); console.log(JSON.stringify(capture.manifest.scenario.environment)); JS ``` 3. Run the direct child-process control, which confirms that the operating system and Node accept this exact key. ```sh node - <<'JS' const { execFileSync } = require('node:child_process'); const env = { ...process.env, ...JSON.parse('{"__proto__":"selected-value"}') }; console.log(execFileSync(process.execPath, ['-e', "console.log(Object.hasOwn(process.env,'__proto__'),process.env['__proto__'])"], { env, encoding: 'utf8' })); JS ``` The capture was executed with an isolated home and compiler module cache. These settings do not change the supplied scenario environment. ### Expected behavior An accepted explicit environment override should reach the selected command and remain in the recorded scenario commitment. `docs/process-capture.md:9` says the host environment is inherited before scenario overrides apply. `src/domain/process/processScenario.ts:35` defines accepted environment names, and `src/domain/process/processScenario.ts:152` accepts this environment entry. `AGENTS.md:13` requires preservation of caller-selected inputs and metadata. `__proto__` is a nonempty environment name without `=` or NUL and is not the reserved process run identifier. The child should print `selected=true:selected-value`, followed by `control=control-value`, and the recorded environment should contain both entries. ### Impact and scope Impact level P2, supported. Captures using this accepted environment key run with a different input from the one requested, and their Evidence omits the lost explicit override. The demonstrated scope is this particular key; other tested object-like names and the ordinary control were preserved. A verified runtime workaround is available. Use `/usr/bin/env` as the scenario executable, put `__proto__=selected-value` and `node` before the existing arguments, and leave the ordinary control override in `environment`. That capture printed `selected=true:selected-value` and `control=control-value`. This preserves the target's runtime input but changes how the environment choice appears in the scenario, so it does not remove the need to preserve accepted scenario overrides. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0 and v24.21.0, macOS 27.0.1 arm64. The CLI failure reproduced on both Node.js versions. ### Target and artifact context A synthetic process-capture JSON scenario launches `node -e` to print a selected literal environment variable and an ordinary control. The affected artifact is the captured scenario environment in `capture.manifest.scenario.environment`. ### Interface CLI ### Earliest failure stage Request parsing or validation ### Relevant output or evidence The CLI succeeds, but the captured output and recorded scenario environment are: ```text selected=false:[object Object] control=control-value {"NORMAL_CAPTURE_KEY":"control-value"} ``` The direct child-process control prints `true selected-value`. The schema-boundary check also succeeds while returning an environment with no own `__proto__` key. These are reproduced observations; the source references below explain the boundary at which the entry is lost. ### Typed outcome or recovery expected Run the supplied scenario and confirm both environment entries reach the child and appear in the recorded scenario. Require `selected=true:selected-value` and `control=control-value`; the ordinary control must continue to pass. Compare the direct child-process control, which already delivers the selected value. Keep the existing rejection of empty names, names containing `=` or NUL, and the reserved process run identifier. Preserve the literal environment entry without treating it as an object-prototype operation. ### Analysis or suspected cause Source-derived reasoning: `src/application/process/ProcessCli.ts:59` parses the scenario before capture. The record parser at `src/domain/process/processScenario.ts:152` accepts this input while omitting the entry, so the launch and scenario commitment receive the reduced environment. The accepted-name contract is defined at `src/domain/process/processScenario.ts:35`. The executed capture, its recorded environment, and the direct child-process control establish the failure; this reasoning does not claim a broader prototype-mutation vulnerability. ### Additional context - [#1036](https://github.com/morluto/rea/issues/1036), closed as completed, covered prototype-named members at shared JSON/Evidence boundaries; this case demonstrates loss in the typed process-scenario environment record before launch and remains reproducible after that completed work. - [#1594](https://github.com/morluto/rea/issues/1594), closed as completed, covered binary plist dictionary-key omission and missing limitations; this case requires preserving a literal process environment override. - [#1498](https://github.com/morluto/rea/issues/1498), closed as completed, covered Windows PATH/Path canonicalization during executable resolution; this case drops a different accepted environment key during scenario parsing on macOS. - [#2974](https://github.com/morluto/rea/issues/2974) covers the Electron provider's Windows environment allowlist dropping ambient platform variables; this case uses process capture and an explicit accepted scenario override.
Author
Owner

@dayvison2b commented on GitHub (Oct 15, 2026):

I'll take this issue. I'll work on a focused fix with regression coverage and open a separate draft PR linked to #3338.

<!-- gh-comment-id:6183645872 --> @dayvison2b commented on GitHub (Oct 15, 2026): I'll take this issue. I'll work on a focused fix with regression coverage and open a separate draft PR linked to #3338.
Author
Owner

@skyvanguard commented on GitHub (Oct 15, 2026):

I looked into this. Two layers drop __proto__, and only the first is in REA's own code.

1. Scenario parsing (observed). zod 4.4.3 z.record skips the own key "__proto__" without validating or reporting it ($ZodRecord in zod/v4/core/schemas.js: if (key === "__proto__") continue;). processScenarioSchema.environment is such a record, which explains the schema-boundary result in the report.

2. PTY launch (observed in the dependency's code). With the schema fixed, @lydell/node-pty 1.1.0 still copies opt.env with its utils.assign, which does target[key] = source[key] (unixTerminal.js:47). Assigning __proto__ on a plain object calls the prototype setter, so the copy loses it before _parseEnv builds the child environment. Running the installed assign directly:

scenario env own keys: [ '__proto__', 'NORMAL_CAPTURE_KEY' ]
node-pty assign() own keys: [ 'NORMAL_CAPTURE_KEY' ]
spread own keys: [ '__proto__', 'NORMAL_CAPTURE_KEY' ]

Upstream node-pty 1.1.0 has the same assign. Preserving the entry in the schema alone would therefore make the recorded scenario claim an override the child never received.

Proposed fix: fail closed (draft #3834). Validate an own __proto__ member instead of letting zod skip it, and reserve the name alongside REA_PROCESS_RUN_ID with a diagnostic that points to your /usr/bin/env __proto__=VALUE workaround. The advertised propertyNames pattern excludes it too, so the catalog matches enforcement in the CLI and over MCP. If node-pty stops copying the environment by assignment, accepting the name again only requires removing the reservation, because the record helper already keeps an accepted __proto__ as a data property.

Related but not changed: nativeCallObservationInputSchema.environment uses the same z.record shape and would drop __proto__ the same way. Its launch path differs and I could not verify it.

Limitation: I worked on Windows, where native process capture is unavailable, so I did not run the end-to-end capture (selected=…). The node-pty finding comes from its source and from executing its assign directly.

<!-- gh-comment-id:6184019230 --> @skyvanguard commented on GitHub (Oct 15, 2026): I looked into this. Two layers drop `__proto__`, and only the first is in REA's own code. **1. Scenario parsing (observed).** zod 4.4.3 `z.record` skips the own key `"__proto__"` without validating or reporting it (`$ZodRecord` in `zod/v4/core/schemas.js`: `if (key === "__proto__") continue;`). `processScenarioSchema.environment` is such a record, which explains the schema-boundary result in the report. **2. PTY launch (observed in the dependency's code).** With the schema fixed, `@lydell/node-pty` 1.1.0 still copies `opt.env` with its `utils.assign`, which does `target[key] = source[key]` (`unixTerminal.js:47`). Assigning `__proto__` on a plain object calls the prototype setter, so the copy loses it before `_parseEnv` builds the child environment. Running the installed `assign` directly: ``` scenario env own keys: [ '__proto__', 'NORMAL_CAPTURE_KEY' ] node-pty assign() own keys: [ 'NORMAL_CAPTURE_KEY' ] spread own keys: [ '__proto__', 'NORMAL_CAPTURE_KEY' ] ``` Upstream `node-pty` 1.1.0 has the same `assign`. Preserving the entry in the schema alone would therefore make the recorded scenario claim an override the child never received. **Proposed fix: fail closed (draft #3834).** Validate an own `__proto__` member instead of letting zod skip it, and reserve the name alongside `REA_PROCESS_RUN_ID` with a diagnostic that points to your `/usr/bin/env __proto__=VALUE` workaround. The advertised `propertyNames` pattern excludes it too, so the catalog matches enforcement in the CLI and over MCP. If node-pty stops copying the environment by assignment, accepting the name again only requires removing the reservation, because the record helper already keeps an accepted `__proto__` as a data property. Related but not changed: `nativeCallObservationInputSchema.environment` uses the same `z.record` shape and would drop `__proto__` the same way. Its launch path differs and I could not verify it. Limitation: I worked on Windows, where native process capture is unavailable, so I did not run the end-to-end capture (`selected=…`). The node-pty finding comes from its source and from executing its `assign` directly.
Author
Owner

@nikolay-dementiev commented on GitHub (Oct 15, 2026):

The PTY layer is in node-pty itself: spawn copies the environment with assign, whose target[key] = source[key] turns an own __proto__ key into a prototype assignment, so the child never receives it. The official node-pty 1.1.0 and @lydell/node-pty 1.1.0 behave the same (false:[object Object] from the child, while child_process gives true:selected-value). Reported upstream as microsoft/node-pty#993. Once a release with that fix reaches @lydell/node-pty, capture-process can keep the caller-selected entry end to end; until then the explicit rejection in #3834 avoids the silent drop.

<!-- gh-comment-id:6186838524 --> @nikolay-dementiev commented on GitHub (Oct 15, 2026): The PTY layer is in node-pty itself: `spawn` copies the environment with `assign`, whose `target[key] = source[key]` turns an own `__proto__` key into a prototype assignment, so the child never receives it. The official `node-pty` 1.1.0 and `@lydell/node-pty` 1.1.0 behave the same (`false:[object Object]` from the child, while `child_process` gives `true:selected-value`). Reported upstream as microsoft/node-pty#993. Once a release with that fix reaches `@lydell/node-pty`, `capture-process` can keep the caller-selected entry end to end; until then the explicit rejection in #3834 avoids the silent drop.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

At d87e73f, the silent-drop behavior has been mitigated, but the original delivery requirement is still unsupported. The owning schema now detects an own __proto__ member before record parsing and rejects it with the PTY diagnostic. The regression assertions require rejection for both string and numeric values; they do not establish that a literal override reaches the child or remains in the recorded scenario.

The current guide documents this restriction and the /usr/bin/env workaround. The dependency is still pinned to @lydell/node-pty 1.1.0. The discussion contains earlier dependency observations; I have not rerun them or process capture on this revision.

The remaining choice is whether explicit refusal is the accepted final contract or only an interim mitigation. For the original requested outcome, closure needs a verified preservation path through parsing, PTY launch, and recording, followed by the original scenario printing selected=true:selected-value and control=control-value, with both own entries present in the captured scenario. CLI and MCP admission should agree, while invalid names and REA_PROCESS_RUN_ID remain rejected. If maintainers instead accept permanent refusal, that decision should explicitly narrow the issue's acceptance criteria; the current rejection tests support that narrower outcome.

<!-- gh-comment-id:6380888070 --> @coygeek commented on GitHub (Oct 24, 2026): At `d87e73f`, the silent-drop behavior has been mitigated, but the original delivery requirement is still unsupported. The [owning schema](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/process/processScenario.ts#L48-L84) now detects an own `__proto__` member before record parsing and rejects it with the PTY diagnostic. The [regression assertions](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/process/processScenario.test.ts#L284-L304) require rejection for both string and numeric values; they do not establish that a literal override reaches the child or remains in the recorded scenario. The [current guide](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/docs/process-capture.md#L15-L18) documents this restriction and the `/usr/bin/env` workaround. The dependency is still [pinned to `@lydell/node-pty` 1.1.0](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/package.json#L339). The discussion contains earlier dependency observations; I have not rerun them or process capture on this revision. The remaining choice is whether explicit refusal is the accepted final contract or only an interim mitigation. For the original requested outcome, closure needs a verified preservation path through parsing, PTY launch, and recording, followed by the original scenario printing `selected=true:selected-value` and `control=control-value`, with both own entries present in the captured scenario. CLI and MCP admission should agree, while invalid names and `REA_PROCESS_RUN_ID` remain rejected. If maintainers instead accept permanent refusal, that decision should explicitly narrow the issue's acceptance criteria; the current rejection tests support that narrower outcome.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1209
No description provided.