Skip to content

Commit 254872e

Browse files
committed
fix: block node env path redirects
1 parent 0f3aecb commit 254872e

10 files changed

Lines changed: 95 additions & 34 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ enum HostEnvSecurityPolicy {
135135
"NODE_AUTH_TOKEN",
136136
"NODE_OPTIONS",
137137
"NODE_PATH",
138+
"NODE_REDIRECT_WARNINGS",
138139
"NODE_REPL_EXTERNAL_MODULE",
140+
"NODE_REPL_HISTORY",
139141
"NODE_V8_COVERAGE",
140142
"NPM_TOKEN",
141143
"OBJC_INCLUDE_PATH",
@@ -269,7 +271,9 @@ enum HostEnvSecurityPolicy {
269271
"MYVIMRC",
270272
"NODE_OPTIONS",
271273
"NODE_PATH",
274+
"NODE_REDIRECT_WARNINGS",
272275
"NODE_REPL_EXTERNAL_MODULE",
276+
"NODE_REPL_HISTORY",
273277
"NODE_V8_COVERAGE",
274278
"PACKER_PLUGIN_PATH",
275279
"PERL5LIB",

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`, `NODE_REPL_EXTERNAL_MODULE`, `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.
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`, `NODE_REPL_EXTERNAL_MODULE`, `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`.
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`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_V8_COVERAGE`, `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 & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -691,41 +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", "NODE_REPL_EXTERNAL_MODULE", "NODE_V8_COVERAGE"] },
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", "NODE_REPL_EXTERNAL_MODULE", "NODE_V8_COVERAGE"], () => {
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",
707-
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
708-
NODE_V8_COVERAGE: "/tmp/coverage",
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+
},
709730
},
710731
},
711732
},
712733
},
713-
},
714-
});
715-
716-
try {
717-
expect(process.env.BASH_ENV).toBeUndefined();
718-
expect(process.env.SHELL).toBeUndefined();
719-
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
720-
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
721-
} finally {
722-
restore();
723-
expect(process.env.BASH_ENV).toBeUndefined();
724-
expect(process.env.SHELL).toBeUndefined();
725-
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
726-
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
727-
}
728-
});
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+
);
729754
});
730755

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

src/infra/dotenv.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,9 @@ describe("loadDotEnv", () => {
229229
[
230230
"SAFE_KEY=from-cwd",
231231
"NODE_OPTIONS=--require ./evil.js",
232+
"NODE_REDIRECT_WARNINGS=./warnings.log",
232233
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
234+
"NODE_REPL_HISTORY=./repl-history",
233235
"NODE_V8_COVERAGE=./coverage",
234236
"OPENCLAW_STATE_DIR=./evil-state",
235237
"OPENCLAW_CONFIG_PATH=./evil-config.json",
@@ -251,7 +253,9 @@ describe("loadDotEnv", () => {
251253
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
252254
delete process.env.SAFE_KEY;
253255
delete process.env.NODE_OPTIONS;
256+
delete process.env.NODE_REDIRECT_WARNINGS;
254257
delete process.env.NODE_REPL_EXTERNAL_MODULE;
258+
delete process.env.NODE_REPL_HISTORY;
255259
delete process.env.NODE_V8_COVERAGE;
256260
delete process.env.OPENCLAW_CONFIG_PATH;
257261
delete process.env.ANTHROPIC_BASE_URL;
@@ -271,7 +275,9 @@ describe("loadDotEnv", () => {
271275
expect(process.env.SAFE_KEY).toBe("from-cwd");
272276
expect(process.env.BAR).toBe("from-global");
273277
expect(process.env.NODE_OPTIONS).toBeUndefined();
278+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
274279
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
280+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
275281
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
276282
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
277283
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
@@ -697,7 +703,9 @@ describe("loadCliDotEnv", () => {
697703
"OPENCLAW_CONFIG_PATH=./evil-config.json",
698704
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
699705
"NODE_OPTIONS=--require ./evil.js",
706+
"NODE_REDIRECT_WARNINGS=./warnings.log",
700707
"NODE_REPL_EXTERNAL_MODULE=./evil-repl.js",
708+
"NODE_REPL_HISTORY=./repl-history",
701709
"NODE_V8_COVERAGE=./coverage",
702710
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
703711
"UV_PYTHON=./attacker-python",
@@ -711,7 +719,9 @@ describe("loadCliDotEnv", () => {
711719
delete process.env.OPENCLAW_CONFIG_PATH;
712720
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
713721
delete process.env.NODE_OPTIONS;
722+
delete process.env.NODE_REDIRECT_WARNINGS;
714723
delete process.env.NODE_REPL_EXTERNAL_MODULE;
724+
delete process.env.NODE_REPL_HISTORY;
715725
delete process.env.NODE_V8_COVERAGE;
716726
delete process.env.ANTHROPIC_BASE_URL;
717727
delete process.env.UV_PYTHON;
@@ -726,7 +736,9 @@ describe("loadCliDotEnv", () => {
726736
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
727737
expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
728738
expect(process.env.NODE_OPTIONS).toBeUndefined();
739+
expect(process.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
729740
expect(process.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
741+
expect(process.env.NODE_REPL_HISTORY).toBeUndefined();
730742
expect(process.env.NODE_V8_COVERAGE).toBeUndefined();
731743
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
732744
expect(process.env.UV_PYTHON).toBeUndefined();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
"blockedEverywhereKeys": [
33
"NODE_OPTIONS",
44
"NODE_PATH",
5+
"NODE_REDIRECT_WARNINGS",
56
"NODE_REPL_EXTERNAL_MODULE",
7+
"NODE_REPL_HISTORY",
68
"NODE_V8_COVERAGE",
79
"PYTHONHOME",
810
"PYTHONPATH",

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@
6767
"MYVIMRC",
6868
"NODE_OPTIONS",
6969
"NODE_PATH",
70+
"NODE_REDIRECT_WARNINGS",
7071
"NODE_REPL_EXTERNAL_MODULE",
72+
"NODE_REPL_HISTORY",
7173
"NODE_V8_COVERAGE",
7274
"PACKER_PLUGIN_PATH",
7375
"PERL5LIB",
@@ -246,5 +248,5 @@
246248
"YARN_RC_FILENAME",
247249
"ZDOTDIR"
248250
],
249-
"expectedTotalReportedEntries": 241
251+
"expectedTotalReportedEntries": 243
250252
}

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(241);
94+
expect(baseline.expectedTotalReportedEntries).toBe(243);
9595
expect(sortUniqueUpper(baseline.reportedDangerousEverywhereKeys)).toEqual(
9696
baseline.reportedDangerousEverywhereKeys,
9797
);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,12 @@ describe("isDangerousHostEnvVarName", () => {
191191
expect(isDangerousHostEnvVarName("maven_opts")).toBe(true);
192192
expect(isDangerousHostEnvVarName("MAKEFLAGS")).toBe(true);
193193
expect(isDangerousHostEnvVarName("makeflags")).toBe(true);
194+
expect(isDangerousHostEnvVarName("NODE_REDIRECT_WARNINGS")).toBe(true);
195+
expect(isDangerousHostEnvVarName("node_redirect_warnings")).toBe(true);
194196
expect(isDangerousHostEnvVarName("NODE_REPL_EXTERNAL_MODULE")).toBe(true);
195197
expect(isDangerousHostEnvVarName("node_repl_external_module")).toBe(true);
198+
expect(isDangerousHostEnvVarName("NODE_REPL_HISTORY")).toBe(true);
199+
expect(isDangerousHostEnvVarName("node_repl_history")).toBe(true);
196200
expect(isDangerousHostEnvVarName("NODE_V8_COVERAGE")).toBe(true);
197201
expect(isDangerousHostEnvVarName("node_v8_coverage")).toBe(true);
198202
expect(isDangerousHostEnvVarName("MFLAGS")).toBe(true);
@@ -332,7 +336,9 @@ describe("sanitizeHostExecEnv", () => {
332336
DOCKER_CONTEXT: "trusted-remote",
333337
DOCKER_HOST: "tcp://docker.example.test:2376",
334338
LD_PRELOAD: "/tmp/pwn.so",
339+
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
335340
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
341+
NODE_REPL_HISTORY: "/tmp/node-repl-history",
336342
NODE_V8_COVERAGE: "/tmp/coverage",
337343
OK: "1",
338344
},
@@ -424,7 +430,9 @@ describe("sanitizeHostExecEnv", () => {
424430
CPLUS_INCLUDE_PATH: "/tmp/evil-cpp-headers",
425431
OBJC_INCLUDE_PATH: "/tmp/evil-objc-headers",
426432
HELM_HOME: "/tmp/override-helm",
433+
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
427434
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
435+
NODE_REPL_HISTORY: "/tmp/node-repl-history",
428436
NODE_V8_COVERAGE: "/tmp/coverage",
429437
NODE_EXTRA_CA_CERTS: "/tmp/evil-ca.pem",
430438
SSL_CERT_FILE: "/tmp/evil-cert.pem",
@@ -536,7 +544,9 @@ describe("sanitizeHostExecEnv", () => {
536544
expect(env.GOPATH).toBeUndefined();
537545
expect(env.CARGO_HOME).toBeUndefined();
538546
expect(env.HELM_HOME).toBeUndefined();
547+
expect(env.NODE_REDIRECT_WARNINGS).toBeUndefined();
539548
expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
549+
expect(env.NODE_REPL_HISTORY).toBeUndefined();
540550
expect(env.NODE_V8_COVERAGE).toBeUndefined();
541551
expect(env.PYTHONUSERBASE).toBeUndefined();
542552
expect(env.VIRTUAL_ENV).toBeUndefined();
@@ -992,7 +1002,9 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
9921002
MAKEFLAGS: "--eval=$(shell touch /tmp/pwned)",
9931003
MFLAGS: "--eval=$(shell touch /tmp/pwned-too)",
9941004
HELM_HOME: "/tmp/evil-helm",
1005+
NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log",
9951006
NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js",
1007+
NODE_REPL_HISTORY: "/tmp/node-repl-history",
9961008
NODE_V8_COVERAGE: "/tmp/coverage",
9971009
PYTHONUSERBASE: "/tmp/evil-python-userbase",
9981010
RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper",
@@ -1055,7 +1067,9 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
10551067
"MAKEFLAGS",
10561068
"MFLAGS",
10571069
"NODE_EXTRA_CA_CERTS",
1070+
"NODE_REDIRECT_WARNINGS",
10581071
"NODE_REPL_EXTERNAL_MODULE",
1072+
"NODE_REPL_HISTORY",
10591073
"NODE_TLS_REJECT_UNAUTHORIZED",
10601074
"NODE_V8_COVERAGE",
10611075
"OBJC_INCLUDE_PATH",
@@ -1138,7 +1152,9 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
11381152
expect(result.env.CARGO_HOME).toBeUndefined();
11391153
expect(result.env.HGRCPATH).toBeUndefined();
11401154
expect(result.env.HELM_HOME).toBeUndefined();
1155+
expect(result.env.NODE_REDIRECT_WARNINGS).toBeUndefined();
11411156
expect(result.env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined();
1157+
expect(result.env.NODE_REPL_HISTORY).toBeUndefined();
11421158
expect(result.env.NODE_V8_COVERAGE).toBeUndefined();
11431159
expect(result.env.HTTPS_PROXY).toBeUndefined();
11441160
expect(result.env.JAVA_OPTS).toBeUndefined();

0 commit comments

Comments
 (0)