Skip to content

Commit 9159013

Browse files
authored
Block unsafe Node runtime env overrides [AI] (#87308)
* fix: block unsafe node runtime env overrides * fix: block node env path redirects * docs: add changelog entry for PR merge
1 parent d242774 commit 9159013

11 files changed

Lines changed: 132 additions & 28 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Block unsafe Node runtime env overrides [AI]. (#87308) Thanks @pgondhi987.
1415
- Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky.
1516

1617
## 2026.5.26

apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ enum HostEnvSecurityPolicy {
135135
"NODE_AUTH_TOKEN",
136136
"NODE_OPTIONS",
137137
"NODE_PATH",
138+
"NODE_REDIRECT_WARNINGS",
139+
"NODE_REPL_EXTERNAL_MODULE",
140+
"NODE_REPL_HISTORY",
141+
"NODE_V8_COVERAGE",
138142
"NPM_TOKEN",
139143
"OBJC_INCLUDE_PATH",
140144
"OPENSSL_CONF",
@@ -267,6 +271,10 @@ enum HostEnvSecurityPolicy {
267271
"MYVIMRC",
268272
"NODE_OPTIONS",
269273
"NODE_PATH",
274+
"NODE_REDIRECT_WARNINGS",
275+
"NODE_REPL_EXTERNAL_MODULE",
276+
"NODE_REPL_HISTORY",
277+
"NODE_V8_COVERAGE",
270278
"PACKER_PLUGIN_PATH",
271279
"PERL5LIB",
272280
"PERL5OPT",

docs/cli/mcp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ Launches a local child process and communicates over stdin/stdout.
441441
<Warning>
442442
**Stdio env safety filter**
443443

444-
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, or enable a debugger against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
444+
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
445445

446446
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
447447
</Warning>

docs/nodes/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ Notes:
376376
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
377377
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
378378
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
379-
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
379+
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
380380
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
381381
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
382382
- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`).

docs/platforms/macos.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Notes:
105105
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
106106
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
107107
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
108-
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app's environment.
108+
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app's environment.
109109
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
110110
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
111111

src/agents/skills.test.ts

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -691,35 +691,66 @@ describe("applySkillEnvOverrides", () => {
691691

692692
it("blocks dangerous host env overrides even when declared", () => {
693693
const entries = envSkillEntries("dangerous-env-skill", {
694-
requires: { env: ["BASH_ENV", "SHELL"] },
694+
requires: {
695+
env: [
696+
"BASH_ENV",
697+
"SHELL",
698+
"NODE_REDIRECT_WARNINGS",
699+
"NODE_REPL_EXTERNAL_MODULE",
700+
"NODE_REPL_HISTORY",
701+
"NODE_V8_COVERAGE",
702+
],
703+
},
695704
});
696705

697-
withClearedEnv(["BASH_ENV", "SHELL"], () => {
698-
const restore = applySkillEnvOverrides({
699-
skills: entries,
700-
config: {
701-
skills: {
702-
entries: {
703-
"dangerous-env-skill": {
704-
env: {
705-
BASH_ENV: "/tmp/pwn.sh",
706-
SHELL: "/tmp/evil-shell",
706+
withClearedEnv(
707+
[
708+
"BASH_ENV",
709+
"SHELL",
710+
"NODE_REDIRECT_WARNINGS",
711+
"NODE_REPL_EXTERNAL_MODULE",
712+
"NODE_REPL_HISTORY",
713+
"NODE_V8_COVERAGE",
714+
],
715+
() => {
716+
const restore = applySkillEnvOverrides({
717+
skills: entries,
718+
config: {
719+
skills: {
720+
entries: {
721+
"dangerous-env-skill": {
722+
env: {
723+
BASH_ENV: "/tmp/pwn.sh",
724+
SHELL: "/tmp/evil-shell",
725+
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
726+
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
727+
NODE_REPL_HISTORY: "/tmp/node-repl-history",
728+
NODE_V8_COVERAGE: "/tmp/coverage",
729+
},
707730
},
708731
},
709732
},
710733
},
711-
},
712-
});
713-
714-
try {
715-
expect(process.env.BASH_ENV).toBeUndefined();
716-
expect(process.env.SHELL).toBeUndefined();
717-
} finally {
718-
restore();
719-
expect(process.env.BASH_ENV).toBeUndefined();
720-
expect(process.env.SHELL).toBeUndefined();
721-
}
722-
});
734+
});
735+
736+
try {
737+
expect(process.env.BASH_ENV).toBeUndefined();
738+
expect(process.env.SHELL).toBeUndefined();
739+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
740+
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
741+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
742+
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
743+
} finally {
744+
restore();
745+
expect(process.env.BASH_ENV).toBeUndefined();
746+
expect(process.env.SHELL).toBeUndefined();
747+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
748+
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
749+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
750+
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
751+
}
752+
},
753+
);
723754
});
724755

725756
it("blocks override-only host env overrides in skill config", () => {

src/infra/dotenv.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ describe("loadDotEnv", () => {
229229
[
230230
"SAFE_KEY=from-cwd",
231231
"NODE_OPTIONS=--require ./evil.js",
232+
"NODE_REDIRECT_WARNINGS=./warnings.log",
233+
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
234+
"NODE_REPL_HISTORY=./repl-history",
235+
"NODE_V8_COVERAGE=./coverage",
232236
"OPENCLAW_STATE_DIR=./evil-state",
233237
"OPENCLAW_CONFIG_PATH=./evil-config.json",
234238
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
@@ -249,6 +253,10 @@ describe("loadDotEnv", () => {
249253
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
250254
delete process.env.SAFE_KEY;
251255
delete process.env.NODE_OPTIONS;
256+
delete process.env.NODE_REDIRECT_WARNINGS;
257+
delete process.env.NODE_REPL_EXTERNAL_MODULE;
258+
delete process.env.NODE_REPL_HISTORY;
259+
delete process.env.NODE_V8_COVERAGE;
252260
delete process.env.OPENCLAW_CONFIG_PATH;
253261
delete process.env.ANTHROPIC_BASE_URL;
254262
delete process.env.CLOUDSDK_PYTHON;
@@ -267,6 +275,10 @@ describe("loadDotEnv", () => {
267275
expect(process.env.SAFE_KEY).toBe("from-cwd");
268276
expect(process.env.BAR).toBe("from-global");
269277
expect(process.env.NODE_OPTIONS).toBeUndefined();
278+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
279+
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
280+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
281+
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
270282
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
271283
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
272284
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
@@ -691,6 +703,10 @@ describe("loadCliDotEnv", () => {
691703
"OPENCLAW_CONFIG_PATH=./evil-config.json",
692704
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
693705
"NODE_OPTIONS=--require ./evil.js",
706+
"NODE_REDIRECT_WARNINGS=./warnings.log",
707+
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
708+
"NODE_REPL_HISTORY=./repl-history",
709+
"NODE_V8_COVERAGE=./coverage",
694710
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
695711
"UV_PYTHON=./attacker-python",
696712
"uv_python=./attacker-python-lower",
@@ -703,6 +719,10 @@ describe("loadCliDotEnv", () => {
703719
delete process.env.OPENCLAW_CONFIG_PATH;
704720
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
705721
delete process.env.NODE_OPTIONS;
722+
delete process.env.NODE_REDIRECT_WARNINGS;
723+
delete process.env.NODE_REPL_EXTERNAL_MODULE;
724+
delete process.env.NODE_REPL_HISTORY;
725+
delete process.env.NODE_V8_COVERAGE;
706726
delete process.env.ANTHROPIC_BASE_URL;
707727
delete process.env.UV_PYTHON;
708728
delete process.env.uv_python;
@@ -716,6 +736,10 @@ describe("loadCliDotEnv", () => {
716736
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
717737
expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
718738
expect(process.env.NODE_OPTIONS).toBeUndefined();
739+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
740+
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
741+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
742+
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
719743
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
720744
expect(process.env.UV_PYTHON).toBeUndefined();
721745
expect(process.env.uv_python).toBeUndefined();

src/infra/host-env-security-policy.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
"blockedEverywhereKeys": [
33
"NODE_OPTIONS",
44
"NODE_PATH",
5+
"NODE_REDIRECT_WARNINGS",
6+
"NODE_REPL_EXTERNAL_MODULE",
7+
"NODE_REPL_HISTORY",
8+
"NODE_V8_COVERAGE",
59
"PYTHONHOME",
610
"PYTHONPATH",
711
"PERL5LIB",

src/infra/host-env-security.reported-baseline.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
"MYVIMRC",
6868
"NODE_OPTIONS",
6969
"NODE_PATH",
70+
"NODE_REDIRECT_WARNINGS",
71+
"NODE_REPL_EXTERNAL_MODULE",
72+
"NODE_REPL_HISTORY",
73+
"NODE_V8_COVERAGE",
7074
"PACKER_PLUGIN_PATH",
7175
"PERL5LIB",
7276
"PERL5OPT",
@@ -244,5 +248,5 @@
244248
"YARN_RC_FILENAME",
245249
"ZDOTDIR"
246250
],
247-
"expectedTotalReportedEntries": 239
251+
"expectedTotalReportedEntries": 243
248252
}

src/infra/host-env-security.reported-baseline.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe("host env reported baseline coverage", () => {
9191
baseline.reportedDangerousEverywhereKeys.length +
9292
baseline.reportedDangerousOverrideOnlyKeys.length,
9393
).toBe(baseline.expectedTotalReportedEntries);
94-
expect(baseline.expectedTotalReportedEntries).toBe(239);
94+
expect(baseline.expectedTotalReportedEntries).toBe(243);
9595
expect(sortUniqueUpper(baseline.reportedDangerousEverywhereKeys)).toEqual(
9696
baseline.reportedDangerousEverywhereKeys,
9797
);

0 commit comments

Comments
 (0)