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
This commit is contained in:
Pavan Kumar Gondhi
2026-05-27 20:34:12 +05:30
committed by GitHub
parent d242774ec6
commit 91590132f6
11 changed files with 131 additions and 27 deletions
+1
View File
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Block unsafe Node runtime env overrides [AI]. (#87308) Thanks @pgondhi987.
- 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.
## 2026.5.26
@@ -135,6 +135,10 @@ enum HostEnvSecurityPolicy {
"NODE_AUTH_TOKEN",
"NODE_OPTIONS",
"NODE_PATH",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
"NPM_TOKEN",
"OBJC_INCLUDE_PATH",
"OPENSSL_CONF",
@@ -267,6 +271,10 @@ enum HostEnvSecurityPolicy {
"MYVIMRC",
"NODE_OPTIONS",
"NODE_PATH",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
"PACKER_PLUGIN_PATH",
"PERL5LIB",
"PERL5OPT",
+1 -1
View File
@@ -441,7 +441,7 @@ Launches a local child process and communicates over stdin/stdout.
<Warning>
**Stdio env safety filter**
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.
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.
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`.
</Warning>
+1 -1
View File
@@ -376,7 +376,7 @@ Notes:
- 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.
- 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).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- 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`.
- 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`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`).
+1 -1
View File
@@ -105,7 +105,7 @@ Notes:
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
- 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).
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
- `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.
- `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.
- 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`).
- 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.
+53 -22
View File
@@ -691,35 +691,66 @@ describe("applySkillEnvOverrides", () => {
it("blocks dangerous host env overrides even when declared", () => {
const entries = envSkillEntries("dangerous-env-skill", {
requires: { env: ["BASH_ENV", "SHELL"] },
requires: {
env: [
"BASH_ENV",
"SHELL",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
],
},
});
withClearedEnv(["BASH_ENV", "SHELL"], () => {
const restore = applySkillEnvOverrides({
skills: entries,
config: {
skills: {
entries: {
"dangerous-env-skill": {
env: {
BASH_ENV: "/tmp/pwn.sh",
SHELL: "/tmp/evil-shell",
withClearedEnv(
[
"BASH_ENV",
"SHELL",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
],
() => {
const restore = applySkillEnvOverrides({
skills: entries,
config: {
skills: {
entries: {
"dangerous-env-skill": {
env: {
BASH_ENV: "/tmp/pwn.sh",
SHELL: "/tmp/evil-shell",
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
NODE_REPL_HISTORY: "/tmp/node-repl-history",
NODE_V8_COVERAGE: "/tmp/coverage",
},
},
},
},
},
},
});
});
try {
expect(process.env.BASH_ENV).toBeUndefined();
expect(process.env.SHELL).toBeUndefined();
} finally {
restore();
expect(process.env.BASH_ENV).toBeUndefined();
expect(process.env.SHELL).toBeUndefined();
}
});
try {
expect(process.env.BASH_ENV).toBeUndefined();
expect(process.env.SHELL).toBeUndefined();
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
} finally {
restore();
expect(process.env.BASH_ENV).toBeUndefined();
expect(process.env.SHELL).toBeUndefined();
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
}
},
);
});
it("blocks override-only host env overrides in skill config", () => {
+24
View File
@@ -229,6 +229,10 @@ describe("loadDotEnv", () => {
[
"SAFE_KEY=from-cwd",
"NODE_OPTIONS=--require ./evil.js",
"NODE_REDIRECT_WARNINGS=./warnings.log",
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
"NODE_REPL_HISTORY=./repl-history",
"NODE_V8_COVERAGE=./coverage",
"OPENCLAW_STATE_DIR=./evil-state",
"OPENCLAW_CONFIG_PATH=./evil-config.json",
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
@@ -249,6 +253,10 @@ describe("loadDotEnv", () => {
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.SAFE_KEY;
delete process.env.NODE_OPTIONS;
delete process.env.NODE_REDIRECT_WARNINGS;
delete process.env.NODE_REPL_EXTERNAL_MODULE;
delete process.env.NODE_REPL_HISTORY;
delete process.env.NODE_V8_COVERAGE;
delete process.env.OPENCLAW_CONFIG_PATH;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.CLOUDSDK_PYTHON;
@@ -267,6 +275,10 @@ describe("loadDotEnv", () => {
expect(process.env.SAFE_KEY).toBe("from-cwd");
expect(process.env.BAR).toBe("from-global");
expect(process.env.NODE_OPTIONS).toBeUndefined();
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
@@ -691,6 +703,10 @@ describe("loadCliDotEnv", () => {
"OPENCLAW_CONFIG_PATH=./evil-config.json",
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
"NODE_OPTIONS=--require ./evil.js",
"NODE_REDIRECT_WARNINGS=./warnings.log",
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
"NODE_REPL_HISTORY=./repl-history",
"NODE_V8_COVERAGE=./coverage",
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
"UV_PYTHON=./attacker-python",
"uv_python=./attacker-python-lower",
@@ -703,6 +719,10 @@ describe("loadCliDotEnv", () => {
delete process.env.OPENCLAW_CONFIG_PATH;
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
delete process.env.NODE_OPTIONS;
delete process.env.NODE_REDIRECT_WARNINGS;
delete process.env.NODE_REPL_EXTERNAL_MODULE;
delete process.env.NODE_REPL_HISTORY;
delete process.env.NODE_V8_COVERAGE;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.UV_PYTHON;
delete process.env.uv_python;
@@ -716,6 +736,10 @@ describe("loadCliDotEnv", () => {
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
expect(process.env.NODE_OPTIONS).toBeUndefined();
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(process.env.UV_PYTHON).toBeUndefined();
expect(process.env.uv_python).toBeUndefined();
+4
View File
@@ -2,6 +2,10 @@
"blockedEverywhereKeys": [
"NODE_OPTIONS",
"NODE_PATH",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
@@ -67,6 +67,10 @@
"MYVIMRC",
"NODE_OPTIONS",
"NODE_PATH",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
"PACKER_PLUGIN_PATH",
"PERL5LIB",
"PERL5OPT",
@@ -244,5 +248,5 @@
"YARN_RC_FILENAME",
"ZDOTDIR"
],
"expectedTotalReportedEntries": 239
"expectedTotalReportedEntries": 243
}
@@ -91,7 +91,7 @@ describe("host env reported baseline coverage", () => {
baseline.reportedDangerousEverywhereKeys.length +
baseline.reportedDangerousOverrideOnlyKeys.length,
).toBe(baseline.expectedTotalReportedEntries);
expect(baseline.expectedTotalReportedEntries).toBe(239);
expect(baseline.expectedTotalReportedEntries).toBe(243);
expect(sortUniqueUpper(baseline.reportedDangerousEverywhereKeys)).toEqual(
baseline.reportedDangerousEverywhereKeys,
);
+32
View File
@@ -191,6 +191,14 @@ describe("isDangerousHostEnvVarName", () => {
expect(isDangerousHostEnvVarName("maven_opts")).toBe(true);
expect(isDangerousHostEnvVarName("MAKEFLAGS")).toBe(true);
expect(isDangerousHostEnvVarName("makeflags")).toBe(true);
expect(isDangerousHostEnvVarName("NODE_REDIRECT_WARNINGS")).toBe(true);
expect(isDangerousHostEnvVarName("node_redirect_warnings")).toBe(true);
expect(isDangerousHostEnvVarName("NODE_REPL_EXTERNAL_MODULE")).toBe(true);
expect(isDangerousHostEnvVarName("node_repl_external_module")).toBe(true);
expect(isDangerousHostEnvVarName("NODE_REPL_HISTORY")).toBe(true);
expect(isDangerousHostEnvVarName("node_repl_history")).toBe(true);
expect(isDangerousHostEnvVarName("NODE_V8_COVERAGE")).toBe(true);
expect(isDangerousHostEnvVarName("node_v8_coverage")).toBe(true);
expect(isDangerousHostEnvVarName("MFLAGS")).toBe(true);
expect(isDangerousHostEnvVarName("mflags")).toBe(true);
expect(isDangerousHostEnvVarName("SBT_OPTS")).toBe(true);
@@ -328,6 +336,10 @@ describe("sanitizeHostExecEnv", () => {
DOCKER_CONTEXT: "trusted-remote",
DOCKER_HOST: "tcp://docker.example.test:2376",
LD_PRELOAD: "/tmp/pwn.so",
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
NODE_REPL_HISTORY: "/tmp/node-repl-history",
NODE_V8_COVERAGE: "/tmp/coverage",
OK: "1",
},
});
@@ -418,6 +430,10 @@ describe("sanitizeHostExecEnv", () => {
CPLUS_INCLUDE_PATH: "/tmp/evil-cpp-headers",
OBJC_INCLUDE_PATH: "/tmp/evil-objc-headers",
HELM_HOME: "/tmp/override-helm",
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
NODE_REPL_HISTORY: "/tmp/node-repl-history",
NODE_V8_COVERAGE: "/tmp/coverage",
NODE_EXTRA_CA_CERTS: "/tmp/evil-ca.pem",
SSL_CERT_FILE: "/tmp/evil-cert.pem",
SSL_CERT_DIR: "/tmp/evil-cert-dir",
@@ -528,6 +544,10 @@ describe("sanitizeHostExecEnv", () => {
expect(env.GOPATH).toBeUndefined();
expect(env.CARGO_HOME).toBeUndefined();
expect(env.HELM_HOME).toBeUndefined();
expect(env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(env.NODE_REPL_HISTORY).toBeUndefined();
expect(env.NODE_V8_COVERAGE).toBeUndefined();
expect(env.PYTHONUSERBASE).toBeUndefined();
expect(env.VIRTUAL_ENV).toBeUndefined();
expect(env.SAFE).toBe("ok");
@@ -982,6 +1002,10 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
MAKEFLAGS: "--eval=$(shell touch /tmp/pwned)",
MFLAGS: "--eval=$(shell touch /tmp/pwned-too)",
HELM_HOME: "/tmp/evil-helm",
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
NODE_REPL_HISTORY: "/tmp/node-repl-history",
NODE_V8_COVERAGE: "/tmp/coverage",
PYTHONUSERBASE: "/tmp/evil-python-userbase",
RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper",
RUSTFLAGS: "-C link-args=-l/tmp/evil.so",
@@ -1043,7 +1067,11 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
"MAKEFLAGS",
"MFLAGS",
"NODE_EXTRA_CA_CERTS",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_TLS_REJECT_UNAUTHORIZED",
"NODE_V8_COVERAGE",
"OBJC_INCLUDE_PATH",
"PATH",
"PIP_CONFIG_FILE",
@@ -1124,6 +1152,10 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
expect(result.env.CARGO_HOME).toBeUndefined();
expect(result.env.HGRCPATH).toBeUndefined();
expect(result.env.HELM_HOME).toBeUndefined();
expect(result.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
expect(result.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
expect(result.env.NODE_REPL_HISTORY).toBeUndefined();
expect(result.env.NODE_V8_COVERAGE).toBeUndefined();
expect(result.env.HTTPS_PROXY).toBeUndefined();
expect(result.env.JAVA_OPTS).toBeUndefined();
expect(result.env.MAKEFLAGS).toBeUndefined();