fix(exec): honor yolo host exec semantics

This commit is contained in:
Peter Steinberger
2026-04-21 07:23:46 +01:00
parent 6ce17db11a
commit 4a846dd129
5 changed files with 86 additions and 26 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Exec/YOLO: stop rejecting gateway-host exec in `security=full` plus `ask=off` mode via the Python/Node script preflight hardening path, so promptless YOLO exec once again runs direct interpreter stdin and heredoc forms such as `node <<'NODE' ... NODE`.
- fix(qqbot): add SSRF guard to direct-upload URL paths in uploadC2CMedia and uploadGroupMedia [AI-assisted]. (#69595) Thanks @pgondhi987.
- fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys. (#69381) Thanks @pgondhi987.
- Webchat/images: treat inline image attachments as media for empty-turn gating while still ignoring metadata-only blank turns. (#69474) Thanks @Jaswir.

View File

@@ -118,7 +118,7 @@ Important distinction:
- `tools.exec.host=auto` chooses where exec runs: sandbox when available, otherwise gateway.
- YOLO chooses how host exec is approved: `security=full` plus `ask=off`.
- In YOLO mode, OpenClaw does not add a separate heuristic command-obfuscation approval gate on top of the configured host exec policy.
- In YOLO mode, OpenClaw does not add a separate heuristic command-obfuscation approval gate or script-preflight rejection layer on top of the configured host exec policy.
- `auto` does not make gateway routing a free override from a sandboxed session. A per-call `host=node` request is allowed from `auto`, and `host=gateway` is only allowed from `auto` when no sandbox runtime is active. If you want a stable non-auto default, set `tools.exec.host` or use `/exec host=...` explicitly.
If you want a more conservative setup, tighten either layer back to `allowlist` / `on-miss`

View File

@@ -66,7 +66,7 @@ Notes:
- `tools.exec.ask` (default: `off`)
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#no-approval-yolo-mode).
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter.
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
- `tools.exec.node` (default: unset)
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` always require explicit approval. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms still prompt each time.
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).

View File

@@ -11,6 +11,8 @@ const isWin = process.platform === "win32";
const describeNonWin = isWin ? describe.skip : describe;
const describeWin = isWin ? describe : describe.skip;
const validateExecScriptPreflight = __testing.validateScriptFileForShellBleed;
const createPreflightTool = () =>
createExecTool({ host: "gateway", security: "full", ask: "on-miss" });
afterEach(() => {
__setFsSafeTestHooksForTest();
@@ -66,7 +68,7 @@ describeNonWin("exec script preflight", () => {
"utf-8",
);
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const tool = createPreflightTool();
await expect(
tool.execute("call1", {
@@ -87,7 +89,7 @@ describeNonWin("exec script preflight", () => {
"utf-8",
);
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const tool = createPreflightTool();
await expect(
tool.execute("call1", {
@@ -105,7 +107,7 @@ describeNonWin("exec script preflight", () => {
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 tool = createPreflightTool();
await expect(
tool.execute("call-quoted", {
command: 'node "bad.js"',
@@ -120,7 +122,7 @@ describeNonWin("exec script preflight", () => {
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 tool = createPreflightTool();
await expect(
tool.execute("call-dotdot-prefix-script", {
command: "node ..bad.js",
@@ -137,7 +139,7 @@ describeNonWin("exec script preflight", () => {
await fs.writeFile(targetPath, "const value = $DM_JSON;", "utf-8");
await fs.symlink(targetPath, linkPath);
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const tool = createPreflightTool();
await expect(
tool.execute("call-symlink-entrypoint", {
command: "node link.js",
@@ -153,7 +155,7 @@ describeNonWin("exec script preflight", () => {
await fs.mkdir(literalTildeDir, { recursive: true });
await fs.writeFile(path.join(literalTildeDir, "bad.js"), "const value = $DM_JSON;", "utf-8");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const tool = createPreflightTool();
await expect(
tool.execute("call-literal-tilde-path", {
command: 'node "~/bad.js"',
@@ -168,7 +170,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-env-python", {
command: "env python bad.py",
@@ -183,7 +185,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-abs-env-python", {
command: "/usr/bin/env python bad.py",
@@ -198,7 +200,7 @@ describeNonWin("exec script preflight", () => {
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 tool = createPreflightTool();
await expect(
tool.execute("call-env-node", {
command: "env node bad.js",
@@ -213,7 +215,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-python-first-script", {
command: "python bad.py ghost.py",
@@ -228,7 +230,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-python-trailing-option-value", {
command: "python script.py --output out.py",
@@ -243,7 +245,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-first-script", {
command: "node app.js config.js",
@@ -258,7 +260,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-require-script", {
command: "node --require bootstrap.js app.js",
@@ -273,7 +275,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-preload-before-entry", {
command: "node --require bad-preload.js app.js",
@@ -287,7 +289,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-require-only", {
command: "node --require bad.js",
@@ -301,7 +303,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-import-only", {
command: "node --import bad.js",
@@ -315,7 +317,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-require-with-eval", {
command: 'node --require bad.js -e "console.log(123)"',
@@ -329,7 +331,7 @@ describeNonWin("exec script preflight", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-node-import-with-eval", {
command: 'node --import bad.js -e "console.log(123)"',
@@ -339,6 +341,46 @@ describeNonWin("exec script preflight", () => {
});
});
it("skips script-file preflight in yolo host mode", 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",
allowBackground: false,
});
const result = await tool.execute("call-yolo-bad-js", {
command: "node bad.js",
workdir: tmp,
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).not.toMatch(/exec preflight:/);
expect(result.details).toMatchObject({
status: expect.stringMatching(/completed|failed/),
});
});
});
it("runs heredoc-backed node commands in yolo host mode", async () => {
const tool = createExecTool({
host: "gateway",
security: "full",
ask: "off",
allowBackground: false,
});
const result = await tool.execute("call-yolo-heredoc", {
command: "node <<'NODE'\nprocess.stdout.write('ok')\nNODE",
});
const text = result.content.find((c) => c.type === "text")?.text?.trim();
expect(result.details).toMatchObject({ status: "completed" });
expect(text).toBe("ok");
});
it("skips preflight file reads for script paths outside the workdir", async () => {
await withTempDir("openclaw-exec-preflight-parent-", async (parent) => {
const outsidePath = path.join(parent, "outside.js");
@@ -459,7 +501,7 @@ describeWin("exec script preflight on windows path syntax", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-win-python-relative", {
command: "python .\\bad.py",
@@ -473,7 +515,7 @@ describeWin("exec script preflight on windows path syntax", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-win-node-relative", {
command: "node .\\bad.js",
@@ -489,7 +531,7 @@ describeWin("exec script preflight on windows path syntax", () => {
await fs.writeFile(absPath, "payload = $DM_JSON", "utf-8");
const winAbsPath = absPath.replaceAll("/", "\\");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const tool = createPreflightTool();
await expect(
tool.execute("call-win-python-absolute", {
command: `python "${winAbsPath}"`,
@@ -504,7 +546,7 @@ describeWin("exec script preflight on windows path syntax", () => {
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" });
const tool = createPreflightTool();
await expect(
tool.execute("call-win-python-subdir-relative", {
command: "python subdir\\bad.py",

View File

@@ -1,7 +1,14 @@
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js";
import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js";
import {
type ExecAsk,
type ExecHost,
type ExecSecurity,
loadExecApprovals,
maxAsk,
minSecurity,
} from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
import {
@@ -1036,6 +1043,14 @@ async function validateScriptFileForShellBleed(params: {
}
}
function shouldSkipExecScriptPreflight(params: {
host: ExecHost;
security: ExecSecurity;
ask: ExecAsk;
}): boolean {
return params.host === "gateway" && params.security === "full" && params.ask === "off";
}
type ParsedExecApprovalCommand = {
approvalId: string;
decision: "allow-once" | "allow-always" | "deny";
@@ -1670,7 +1685,9 @@ export function createExecTool(
// Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources)
// before we execute and burn tokens in cron loops.
await validateScriptFileForShellBleed({ command: params.command, workdir });
if (!shouldSkipExecScriptPreflight({ host, security, ask })) {
await validateScriptFileForShellBleed({ command: params.command, workdir });
}
const run = await runExecProcess({
command: params.command,