diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ce51793bc..9bedeb6d963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 5a0fd378be3..23d8b86a405 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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` diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 413d18646a3..2ceec3c13a4 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -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). diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index d71d4c539ea..5518bb3fe03 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -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", diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 1ab0e483ecf..888cecef362 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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,