fix(security): close fail-open bypass in exec script preflight [AI] (#59398)

* fix: address issue

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* chore: add changelog for exec preflight fail-closed hardening

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
pgondhi987
2026-04-02 22:30:39 +05:30
committed by GitHub
parent e36c563775
commit 8aceaf5d0f
5 changed files with 1445 additions and 86 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
- Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1.
- Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987.
## 2026.4.2-beta.1

View File

@@ -28,7 +28,6 @@ import {
buildDiscordModalCustomId as buildDiscordModalCustomIdImpl,
parseDiscordModalCustomIdForCarbon as parseDiscordModalCustomIdForCarbonImpl,
} from "./component-custom-id.js";
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = (Modal ?? class {}) as typeof Modal;

View File

@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
let buildDiscordComponentCustomId: typeof import("../component-custom-id.js").buildDiscordComponentCustomId;
let buildDiscordModalCustomId: typeof import("../component-custom-id.js").buildDiscordModalCustomId;
let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton;
let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect;
let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect;
@@ -11,7 +11,8 @@ let createDiscordComponentStringSelect: typeof import("./agent-components.js").c
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
beforeAll(async () => {
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } =
await import("../component-custom-id.js"));
({
createDiscordComponentButton,
createDiscordComponentChannelSelect,

View File

@@ -7,6 +7,7 @@ import { createExecTool } from "./bash-tools.exec.js";
const isWin = process.platform === "win32";
const describeNonWin = isWin ? describe.skip : describe;
const describeWin = isWin ? describe : describe.skip;
describeNonWin("exec script preflight", () => {
it("blocks shell env var injection tokens in python scripts before execution", async () => {
@@ -58,18 +59,194 @@ describeNonWin("exec script preflight", () => {
});
});
it("skips preflight when script token is quoted and unresolved by fast parser", async () => {
it("blocks shell env var injection when script path is quoted", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const jsPath = path.join(tmp, "bad.js");
await fs.writeFile(jsPath, "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-quoted", {
command: 'node "bad.js"',
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).not.toMatch(/exec preflight:/);
await expect(
tool.execute("call-quoted", {
command: 'node "bad.js"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates python scripts when interpreter is prefixed with env", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-env-python", {
command: "env python bad.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates python scripts when interpreter is prefixed with path-qualified env", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-abs-env-python", {
command: "/usr/bin/env python bad.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node scripts when interpreter is prefixed with env", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const jsPath = path.join(tmp, "bad.js");
await fs.writeFile(jsPath, "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-env-node", {
command: "env node bad.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates the first positional python script operand when extra args follow", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.py"), "payload = $DM_JSON", "utf-8");
await fs.writeFile(path.join(tmp, "ghost.py"), "print('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-python-first-script", {
command: "python bad.py ghost.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates python script operand even when trailing option values look like scripts", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "script.py"), "payload = $DM_JSON", "utf-8");
await fs.writeFile(path.join(tmp, "out.py"), "print('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-python-trailing-option-value", {
command: "python script.py --output out.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates the first positional node script operand when extra args follow", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "app.js"), "const value = $DM_JSON;", "utf-8");
await fs.writeFile(path.join(tmp, "config.js"), "console.log('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-first-script", {
command: "node app.js config.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("still resolves node script when --require consumes a preceding .js option value", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bootstrap.js"), "console.log('bootstrap')", "utf-8");
await fs.writeFile(path.join(tmp, "app.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-require-script", {
command: "node --require bootstrap.js app.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node --require preload modules before a benign entry script", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad-preload.js"), "const value = $DM_JSON;", "utf-8");
await fs.writeFile(path.join(tmp, "app.js"), "console.log('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-preload-before-entry", {
command: "node --require bad-preload.js app.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node --require preload modules when no entry script is provided", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-require-only", {
command: "node --require bad.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node --import preload modules when no entry script is provided", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-import-only", {
command: "node --import bad.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node --require preload modules even when -e is present", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-require-with-eval", {
command: 'node --require bad.js -e "console.log(123)"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("validates node --import preload modules even when -e is present", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-node-import-with-eval", {
command: 'node --import bad.js -e "console.log(123)"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
@@ -90,4 +267,519 @@ describeNonWin("exec script preflight", () => {
expect(text).not.toMatch(/exec preflight:/);
});
});
it("fails closed for piped interpreter commands that bypass direct script parsing", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-pipe", {
command: "cat bad.py | python",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for top-level interpreter invocations inside shell control-flow", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-top-level-control-flow", {
command: "if true; then python bad.py; fi",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for multiline top-level control-flow interpreter invocations", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-top-level-control-flow-multiline", {
command: "if true; then\npython bad.py\nfi",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations with quoted script paths", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-quoted-script", {
command: `bash -c "python '${path.basename(pyPath)}'"`,
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for top-level control-flow with quoted interpreter script paths", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-top-level-control-flow-quoted-script", {
command: 'if true; then python "bad.py"; fi',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap", {
command: 'bash -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("does not fail closed for shell-wrapped payloads that only echo interpreter words", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-shell-wrap-echo-text", {
command: 'bash -c "echo python"',
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("python");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("fails closed for shell-wrapped interpreter invocations inside control-flow payloads", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-control-flow", {
command: 'bash -c "if true; then python bad.py; fi"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for env-prefixed shell-wrapped interpreter invocations", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-env-shell-wrap", {
command: 'env bash -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations via absolute shell paths", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-abs-path", {
command: '/bin/bash -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations when long options take separate values", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
await fs.writeFile(path.join(tmp, "shell.rc"), "# rc", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-long-option-value", {
command: 'bash --rcfile shell.rc -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations with leading long options", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-long-options", {
command: 'bash --noprofile --norc -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations with combined shell flags", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-combined", {
command: 'bash -xc "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations when -O consumes a separate value", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-short-option-O-value", {
command: 'bash -O extglob -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations when -o consumes a separate value", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-short-option-o-value", {
command: 'bash -o errexit -c "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for shell-wrapped interpreter invocations when -c is not the trailing short flag", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-shell-wrap-short-flags", {
command: 'bash -ceu "python bad.py"',
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("fails closed for process-substitution interpreter invocations", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");
await fs.writeFile(pyPath, "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-process-substitution", {
command: "python <(cat bad.py)",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: complex interpreter invocation detected/);
});
});
it("allows direct inline interpreter commands with no script file hint", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-inline", {
command: 'node -e "console.log(123)"',
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("123");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when interpreter and script hints only appear in echoed text", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-echo-text", {
command: "echo 'python bad.py | python'",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("python bad.py | python");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when shell keyword-like text appears only as echo arguments", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-echo-keyword-like-text", {
command: "echo time python bad.py; cat",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("time python bad.py");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for pipelines that only contain interpreter words as plain text", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-echo-pipe-text", {
command: "echo python | cat",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("python");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for non-executing pipelines that only print interpreter words", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-printf-pipe-text", {
command: "printf node | wc -c",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("4");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when script-like text is in a separate command segment", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-separate-script-hint-segment", {
command: "echo bad.py; python --version",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("bad.py");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when script hints appear outside the interpreter segment with &&", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "sample.py"), "print('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-interpreter-version-and-list", {
command: "node --version && ls *.py",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("sample.py");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for piped interpreter version commands with script-like upstream text", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-piped-interpreter-version", {
command: "echo bad.py | node --version",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toMatch(/v\d+/);
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for piped node -c syntax-check commands with script-like upstream text", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
await fs.writeFile(path.join(tmp, "ok.js"), "console.log('ok')", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-piped-node-check", {
command: "echo bad.py | node -c ok.js",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for piped node -e commands when inline code contains script-like text", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-piped-node-e-inline-script-hint", {
command: "node -e \"console.log('bad.py')\" | cat",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("bad.py");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when shell operator characters are escaped", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-echo-escaped-operator", {
command: "echo python bad.py \\| node",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("python bad.py | node");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed when escaped semicolons appear with interpreter hints", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-echo-escaped-semicolon", {
command: "echo python bad.py \\; node",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("python bad.py ; node");
expect(text).not.toMatch(/exec preflight:/);
});
});
it("does not fail closed for node -e when .py appears inside quoted inline code", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-inline-script-hint", {
command: "node -e \"console.log('bad.py')\"",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("bad.py");
expect(text).not.toMatch(/exec preflight:/);
});
});
});
describeWin("exec script preflight on windows path syntax", () => {
it("preserves windows-style python relative path separators during script extraction", async () => {
await withTempDir("openclaw-exec-preflight-win-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.py"), "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-win-python-relative", {
command: "python .\\bad.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("preserves windows-style node relative path separators during script extraction", async () => {
await withTempDir("openclaw-exec-preflight-win-", async (tmp) => {
await fs.writeFile(path.join(tmp, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-win-node-relative", {
command: "node .\\bad.js",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("preserves windows-style python absolute drive paths during script extraction", async () => {
await withTempDir("openclaw-exec-preflight-win-", async (tmp) => {
const absPath = path.join(tmp, "bad.py");
await fs.writeFile(absPath, "payload = $DM_JSON", "utf-8");
const winAbsPath = absPath.replaceAll("/", "\\");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-win-python-absolute", {
command: `python "${winAbsPath}"`,
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
it("preserves windows-style nested relative path separators during script extraction", async () => {
await withTempDir("openclaw-exec-preflight-win-", async (tmp) => {
await fs.mkdir(path.join(tmp, "subdir"), { recursive: true });
await fs.writeFile(path.join(tmp, "subdir", "bad.py"), "payload = $DM_JSON", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-win-python-subdir-relative", {
command: "python subdir\\bad.py",
workdir: tmp,
}),
).rejects.toThrow(/exec preflight: detected likely shell variable injection \(\$DM_JSON\)/);
});
});
});

View File

@@ -86,99 +86,765 @@ function buildExecForegroundResult(params: {
});
}
const PREFLIGHT_ENV_OPTIONS_WITH_VALUES = new Set([
"-C",
"-S",
"-u",
"--argv0",
"--block-signal",
"--chdir",
"--default-signal",
"--ignore-signal",
"--split-string",
"--unset",
]);
function isShellEnvAssignmentToken(token: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token);
}
function isEnvExecutableToken(token: string | undefined): boolean {
if (!token) {
return false;
}
const base = token.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
return normalizedBase === "env";
}
function stripPreflightEnvPrefix(argv: string[]): string[] {
if (argv.length === 0) {
return argv;
}
let idx = 0;
while (idx < argv.length && isShellEnvAssignmentToken(argv[idx])) {
idx += 1;
}
if (!isEnvExecutableToken(argv[idx])) {
return argv;
}
idx += 1;
while (idx < argv.length) {
const token = argv[idx];
if (token === "--") {
idx += 1;
break;
}
if (isShellEnvAssignmentToken(token)) {
idx += 1;
continue;
}
if (!token.startsWith("-") || token === "-") {
break;
}
idx += 1;
const option = token.split("=", 1)[0];
if (
PREFLIGHT_ENV_OPTIONS_WITH_VALUES.has(option) &&
!token.includes("=") &&
idx < argv.length
) {
idx += 1;
}
}
return argv.slice(idx);
}
function extractScriptTargetFromCommand(
command: string,
): { kind: "python"; relOrAbsPath: string } | { kind: "node"; relOrAbsPath: string } | null {
): { kind: "python"; relOrAbsPaths: string[] } | { kind: "node"; relOrAbsPaths: string[] } | null {
const raw = command.trim();
if (!raw) {
const splitShellArgsPreservingBackslashes = (value: string): string[] | null => {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
};
const shouldUseWindowsPathTokenizer =
process.platform === "win32" &&
/(?:^|[\s"'`])(?:[A-Za-z]:\\|\\\\|[^\s"'`|&;()<>]+\\[^\s"'`|&;()<>]+)/.test(raw);
const candidateArgv = shouldUseWindowsPathTokenizer
? [splitShellArgsPreservingBackslashes(raw)]
: [splitShellArgs(raw)];
const findFirstPythonScriptArg = (tokens: string[]): string | null => {
const optionsWithSeparateValue = new Set(["-W", "-X", "-Q", "--check-hash-based-pycs"]);
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === "--") {
const next = tokens[i + 1];
return next?.toLowerCase().endsWith(".py") ? next : null;
}
if (token === "-") {
return null;
}
if (token === "-c" || token === "-m") {
return null;
}
if ((token.startsWith("-c") || token.startsWith("-m")) && token.length > 2) {
return null;
}
if (optionsWithSeparateValue.has(token)) {
i += 1;
continue;
}
if (token.startsWith("-")) {
continue;
}
return token.toLowerCase().endsWith(".py") ? token : null;
}
return null;
};
const findNodeScriptArgs = (tokens: string[]): string[] => {
const optionsWithSeparateValue = new Set(["-r", "--require", "--import"]);
const preloadScripts: string[] = [];
let entryScript: string | null = null;
let hasInlineEvalOrPrint = false;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === "--") {
if (!hasInlineEvalOrPrint && !entryScript) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
entryScript = next;
}
}
break;
}
if (
token === "-e" ||
token === "-p" ||
token === "--eval" ||
token === "--print" ||
token.startsWith("--eval=") ||
token.startsWith("--print=") ||
((token.startsWith("-e") || token.startsWith("-p")) && token.length > 2)
) {
hasInlineEvalOrPrint = true;
if (token === "-e" || token === "-p" || token === "--eval" || token === "--print") {
i += 1;
}
continue;
}
if (optionsWithSeparateValue.has(token)) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
preloadScripts.push(next);
}
i += 1;
continue;
}
if (
(token.startsWith("-r") && token.length > 2) ||
token.startsWith("--require=") ||
token.startsWith("--import=")
) {
const inlineValue = token.startsWith("-r")
? token.slice(2)
: token.slice(token.indexOf("=") + 1);
if (inlineValue.toLowerCase().endsWith(".js")) {
preloadScripts.push(inlineValue);
}
continue;
}
if (token.startsWith("-")) {
continue;
}
if (!hasInlineEvalOrPrint && !entryScript && token.toLowerCase().endsWith(".js")) {
entryScript = token;
}
break;
}
const targets = [...preloadScripts];
if (entryScript) {
targets.push(entryScript);
}
return targets;
};
const extractTargetFromArgv = (
argv: string[] | null,
):
| { kind: "python"; relOrAbsPaths: string[] }
| { kind: "node"; relOrAbsPaths: string[] }
| null => {
if (!argv || argv.length === 0) {
return null;
}
let commandIdx = 0;
while (commandIdx < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])) {
commandIdx += 1;
}
const executable = argv[commandIdx]?.toLowerCase();
if (!executable) {
return null;
}
const args = argv.slice(commandIdx + 1);
if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) {
const script = findFirstPythonScriptArg(args);
if (script) {
return { kind: "python", relOrAbsPaths: [script] };
}
return null;
}
if (executable === "node") {
const scripts = findNodeScriptArgs(args);
if (scripts.length > 0) {
return { kind: "node", relOrAbsPaths: scripts };
}
return null;
}
return null;
};
for (const argv of candidateArgv) {
const attempts = [argv, argv ? stripPreflightEnvPrefix(argv) : null];
for (const attempt of attempts) {
const target = extractTargetFromArgv(attempt);
if (target) {
return target;
}
}
}
return null;
}
function extractUnquotedShellText(raw: string): string | null {
let out = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
if (!inSingle && !inDouble) {
// Preserve escapes outside quotes so downstream heuristics can distinguish
// escaped literals (e.g. `\|`) from executable shell operators.
out += `\\${ch}`;
}
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
}
continue;
}
if (inDouble) {
const next = raw[i + 1];
if (ch === "\\" && next && /[\\'"$`\n\r]/.test(next)) {
i += 1;
continue;
}
if (ch === '"') {
inDouble = false;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
out += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
return out;
}
// Intentionally simple parsing: we only support common forms like
// python file.py
// python3 -u file.py
// node --experimental-something file.js
// If the command is more complex (pipes, heredocs, quoted paths with spaces), skip preflight.
const pythonMatch = raw.match(/^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i);
if (pythonMatch?.[2]) {
return { kind: "python", relOrAbsPath: pythonMatch[2] };
}
const nodeMatch = raw.match(/^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i);
if (nodeMatch?.[2]) {
return { kind: "node", relOrAbsPath: nodeMatch[2] };
}
function analyzeInterpreterHeuristicsFromUnquoted(raw: string): {
hasPython: boolean;
hasNode: boolean;
hasComplexSyntax: boolean;
hasProcessSubstitution: boolean;
hasScriptHint: boolean;
} {
const hasPython =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*python(?:3(?:\.\d+)?)?(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasNode =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*node(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasProcessSubstitution = /(?<!\\)<\(|(?<!\\)>\(/u.test(raw);
const hasComplexSyntax =
/(?<!\\)\|/u.test(raw) ||
/(?<!\\)&&/u.test(raw) ||
/(?<!\\)\|\|/u.test(raw) ||
/(?<!\\);/u.test(raw) ||
raw.includes("\n") ||
raw.includes("\r") ||
/(?<!\\)\$\(/u.test(raw) ||
/(?<!\\)`/u.test(raw) ||
hasProcessSubstitution;
const hasScriptHint = /(?:^|[\s|&;()<>])[^"'`\s|&;()<>]+\.(?:py|js)(?=$|[\s|&;()<>])/i.test(raw);
return { hasPython, hasNode, hasComplexSyntax, hasProcessSubstitution, hasScriptHint };
}
function extractShellWrappedCommandPayload(
executable: string | undefined,
args: string[],
): string | null {
if (!executable) {
return null;
}
const executableBase = executable.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const normalizedExecutable = executableBase.endsWith(".exe")
? executableBase.slice(0, -4)
: executableBase;
if (!/^(?:bash|dash|fish|ksh|sh|zsh)$/i.test(normalizedExecutable)) {
return null;
}
const shortOptionsWithSeparateValue = new Set(["-O", "-o"]);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--") {
return null;
}
if (arg === "-c") {
return args[i + 1] ?? null;
}
if (/^-[A-Za-z]+$/u.test(arg)) {
if (arg.includes("c")) {
return args[i + 1] ?? null;
}
if (shortOptionsWithSeparateValue.has(arg)) {
i += 1;
}
continue;
}
if (/^--[A-Za-z0-9][A-Za-z0-9-]*(?:=.*)?$/u.test(arg)) {
if (!arg.includes("=")) {
const next = args[i + 1];
if (next && next !== "--" && !next.startsWith("-")) {
i += 1;
}
}
continue;
}
return null;
}
return null;
}
function shouldFailClosedInterpreterPreflight(command: string): {
hasInterpreterInvocation: boolean;
hasComplexSyntax: boolean;
hasProcessSubstitution: boolean;
hasInterpreterSegmentScriptHint: boolean;
hasInterpreterPipelineScriptHint: boolean;
isDirectInterpreterCommand: boolean;
} {
const raw = command.trim();
const rawArgv = splitShellArgs(raw);
const argv = rawArgv ? stripPreflightEnvPrefix(rawArgv) : null;
let commandIdx = 0;
while (
argv &&
commandIdx < argv.length &&
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])
) {
commandIdx += 1;
}
const directExecutable = argv?.[commandIdx]?.toLowerCase();
const args = argv ? argv.slice(commandIdx + 1) : [];
const isDirectPythonExecutable = Boolean(
directExecutable && /^python(?:3(?:\.\d+)?)?$/i.test(directExecutable),
);
const isDirectNodeExecutable = directExecutable === "node";
const isDirectInterpreterCommand = isDirectPythonExecutable || isDirectNodeExecutable;
const unquotedRaw = extractUnquotedShellText(raw) ?? raw;
const topLevel = analyzeInterpreterHeuristicsFromUnquoted(unquotedRaw);
const shellWrappedPayload = extractShellWrappedCommandPayload(directExecutable, args);
const nestedUnquoted = shellWrappedPayload
? (extractUnquotedShellText(shellWrappedPayload) ?? shellWrappedPayload)
: "";
const nested = shellWrappedPayload
? analyzeInterpreterHeuristicsFromUnquoted(nestedUnquoted)
: {
hasPython: false,
hasNode: false,
hasComplexSyntax: false,
hasProcessSubstitution: false,
hasScriptHint: false,
};
const splitShellSegmentsOutsideQuotes = (
rawText: string,
params: { splitPipes: boolean },
): string[] => {
const segments: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushSegment = () => {
if (buf.trim().length > 0) {
segments.push(buf);
}
buf = "";
};
for (let i = 0; i < rawText.length; i += 1) {
const ch = rawText[i];
const next = rawText[i + 1];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
buf += ch;
escaped = true;
continue;
}
if (inSingle) {
buf += ch;
if (ch === "'") {
inSingle = false;
}
continue;
}
if (inDouble) {
buf += ch;
if (ch === '"') {
inDouble = false;
}
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "\n" || ch === "\r") {
pushSegment();
continue;
}
if (ch === ";") {
pushSegment();
continue;
}
if (ch === "&" && next === "&") {
pushSegment();
i += 1;
continue;
}
if (ch === "|" && next === "|") {
pushSegment();
i += 1;
continue;
}
if (params.splitPipes && ch === "|") {
pushSegment();
continue;
}
buf += ch;
}
pushSegment();
return segments;
};
const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => {
const segment = extractUnquotedShellText(rawSegment) ?? rawSegment;
return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test(
segment,
);
};
const isScriptExecutingInterpreterCommand = (rawCommand: string): boolean => {
const argv = splitShellArgs(rawCommand.trim());
if (!argv || argv.length === 0) {
return false;
}
const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(argv[0] ?? "")
? argv.slice(1)
: argv;
if (withoutLeadingKeyword.length === 0) {
return false;
}
const normalizedArgv = stripPreflightEnvPrefix(withoutLeadingKeyword);
let commandIdx = 0;
while (
commandIdx < normalizedArgv.length &&
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(normalizedArgv[commandIdx] ?? "")
) {
commandIdx += 1;
}
const executable = normalizedArgv[commandIdx]?.toLowerCase();
if (!executable) {
return false;
}
const args = normalizedArgv.slice(commandIdx + 1);
if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) {
const pythonInfoOnlyFlags = new Set(["-V", "--version", "-h", "--help"]);
if (args.some((arg) => pythonInfoOnlyFlags.has(arg))) {
return false;
}
if (
args.some(
(arg) =>
arg === "-c" ||
arg === "-m" ||
arg.startsWith("-c") ||
arg.startsWith("-m") ||
arg === "--check-hash-based-pycs",
)
) {
return false;
}
return true;
}
if (executable === "node") {
const nodeInfoOnlyFlags = new Set(["-v", "--version", "-h", "--help", "-c", "--check"]);
if (args.some((arg) => nodeInfoOnlyFlags.has(arg))) {
return false;
}
if (
args.some(
(arg) =>
arg === "-e" ||
arg === "-p" ||
arg === "--eval" ||
arg === "--print" ||
arg.startsWith("--eval=") ||
arg.startsWith("--print=") ||
((arg.startsWith("-e") || arg.startsWith("-p")) && arg.length > 2),
)
) {
return false;
}
return true;
}
return false;
};
const hasScriptHintInSegment = (segment: string): boolean =>
/(?:^|[\s()<>])(?:"[^"\n\r`|&;()<>]*\.(?:py|js)"|'[^'\n\r`|&;()<>]*\.(?:py|js)'|[^"'`\s|&;()<>]+\.(?:py|js))(?=$|[\s()<>])/i.test(
segment,
);
const hasInterpreterAndScriptHintInSameSegment = (rawText: string): boolean => {
const segments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: true });
return segments.some((segment) => {
if (!isScriptExecutingInterpreterCommand(segment)) {
return false;
}
return hasScriptHintInSegment(segment);
});
};
const hasInterpreterPipelineScriptHintInSameSegment = (rawText: string): boolean => {
const commandSegments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: false });
return commandSegments.some((segment) => {
const pipelineCommands = splitShellSegmentsOutsideQuotes(segment, { splitPipes: true });
const hasScriptExecutingPipedInterpreter = pipelineCommands
.slice(1)
.some((pipelineCommand) => isScriptExecutingInterpreterCommand(pipelineCommand));
if (!hasScriptExecutingPipedInterpreter) {
return false;
}
return hasScriptHintInSegment(segment);
});
};
const hasInterpreterSegmentScriptHint =
hasInterpreterAndScriptHintInSameSegment(raw) ||
(shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload));
const hasInterpreterPipelineScriptHint =
hasInterpreterPipelineScriptHintInSameSegment(raw) ||
(shellWrappedPayload !== null &&
hasInterpreterPipelineScriptHintInSameSegment(shellWrappedPayload));
const hasShellWrappedInterpreterSegmentScriptHint =
shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload);
const hasShellWrappedInterpreterInvocation =
(nested.hasPython || nested.hasNode) &&
(hasShellWrappedInterpreterSegmentScriptHint ||
nested.hasScriptHint ||
nested.hasComplexSyntax ||
nested.hasProcessSubstitution);
const hasTopLevelInterpreterInvocation = splitShellSegmentsOutsideQuotes(raw, {
splitPipes: true,
}).some((segment) => hasInterpreterInvocationInSegment(segment));
const hasInterpreterInvocation =
isDirectInterpreterCommand ||
hasShellWrappedInterpreterInvocation ||
hasTopLevelInterpreterInvocation;
return {
hasInterpreterInvocation,
hasComplexSyntax: topLevel.hasComplexSyntax || hasShellWrappedInterpreterInvocation,
hasProcessSubstitution: topLevel.hasProcessSubstitution || nested.hasProcessSubstitution,
hasInterpreterSegmentScriptHint,
hasInterpreterPipelineScriptHint,
isDirectInterpreterCommand,
};
}
async function validateScriptFileForShellBleed(params: {
command: string;
workdir: string;
}): Promise<void> {
const target = extractScriptTargetFromCommand(params.command);
if (!target) {
return;
}
const absPath = path.isAbsolute(target.relOrAbsPath)
? path.resolve(target.relOrAbsPath)
: path.resolve(params.workdir, target.relOrAbsPath);
// Best-effort: only validate if file exists and is reasonably small.
let stat: { isFile(): boolean; size: number };
try {
await assertSandboxPath({
filePath: absPath,
cwd: params.workdir,
root: params.workdir,
});
stat = await fs.stat(absPath);
} catch {
return;
}
if (!stat.isFile()) {
return;
}
if (stat.size > 512 * 1024) {
return;
}
const content = await fs.readFile(absPath, "utf-8");
// Common failure mode: shell env var syntax leaking into Python/JS.
// We deliberately match all-caps/underscore vars to avoid false positives with `$` as a JS identifier.
const envVarRegex = /\$[A-Z_][A-Z0-9_]{1,}/g;
const first = envVarRegex.exec(content);
if (first) {
const idx = first.index;
const before = content.slice(0, idx);
const line = before.split("\n").length;
const token = first[0];
throw new Error(
[
`exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${path.basename(
absPath,
)}:${line}.`,
target.kind === "python"
? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.`
: `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.`,
"(If this is inside a string literal on purpose, escape it or restructure the code.)",
].join("\n"),
);
}
// Another recurring pattern from the issue: shell commands accidentally emitted as JS.
if (target.kind === "node") {
const firstNonEmpty = content
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0);
if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
const {
hasInterpreterInvocation,
hasComplexSyntax,
hasProcessSubstitution,
hasInterpreterSegmentScriptHint,
hasInterpreterPipelineScriptHint,
isDirectInterpreterCommand,
} = shouldFailClosedInterpreterPreflight(params.command);
if (
hasInterpreterInvocation &&
hasComplexSyntax &&
(hasInterpreterSegmentScriptHint ||
hasInterpreterPipelineScriptHint ||
(hasProcessSubstitution && isDirectInterpreterCommand))
) {
// Fail closed when interpreter-driven script execution is ambiguous; otherwise
// attackers can route script content through forms our fast parser cannot validate.
throw new Error(
`exec preflight: JS file starts with shell syntax (${firstNonEmpty}). ` +
`This looks like a shell command, not JavaScript.`,
"exec preflight: complex interpreter invocation detected; refusing to run without script preflight validation. " +
"Use a direct `python <file>.py` or `node <file>.js` command.",
);
}
return;
}
for (const relOrAbsPath of target.relOrAbsPaths) {
const absPath = path.isAbsolute(relOrAbsPath)
? path.resolve(relOrAbsPath)
: path.resolve(params.workdir, relOrAbsPath);
// Best-effort: only validate if file exists and is reasonably small.
let stat: { isFile(): boolean; size: number };
try {
await assertSandboxPath({
filePath: absPath,
cwd: params.workdir,
root: params.workdir,
});
stat = await fs.stat(absPath);
} catch {
continue;
}
if (!stat.isFile()) {
continue;
}
if (stat.size > 512 * 1024) {
continue;
}
const content = await fs.readFile(absPath, "utf-8");
// Common failure mode: shell env var syntax leaking into Python/JS.
// We deliberately match all-caps/underscore vars to avoid false positives with `$` as a JS identifier.
const envVarRegex = /\$[A-Z_][A-Z0-9_]{1,}/g;
const first = envVarRegex.exec(content);
if (first) {
const idx = first.index;
const before = content.slice(0, idx);
const line = before.split("\n").length;
const token = first[0];
throw new Error(
[
`exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${path.basename(
absPath,
)}:${line}.`,
target.kind === "python"
? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.`
: `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.`,
"(If this is inside a string literal on purpose, escape it or restructure the code.)",
].join("\n"),
);
}
// Another recurring pattern from the issue: shell commands accidentally emitted as JS.
if (target.kind === "node") {
const firstNonEmpty = content
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0);
if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
throw new Error(
`exec preflight: JS file starts with shell syntax (${firstNonEmpty}). ` +
`This looks like a shell command, not JavaScript.`,
);
}
}
}
}