diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 37994031a6b..fde3d704fd3 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -29,7 +29,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the Notes: -- `host` defaults to `sandbox`. +- `host` defaults to `sandbox` when sandbox runtime is active, and defaults to `gateway` otherwise. - `elevated` is ignored when sandboxing is off (exec already runs on the host). - `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`. - `node` requires a paired node (companion app or headless node host). @@ -38,9 +38,9 @@ Notes: from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. -- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on - the gateway host (no container) and **does not require approvals**. To require approvals, run with - `host=gateway` and configure exec approvals (or enable sandboxing). +- Important: sandboxing is **off by default**. If sandboxing is off and `host=sandbox` is explicitly + configured/requested, exec now fails closed instead of silently running on the gateway host. + Enable sandboxing or use `host=gateway` with approvals. - Script preflight checks (for common Python/Node shell-syntax mistakes) only inspect files inside the effective `workdir` boundary. If a script path resolves outside `workdir`, preflight is skipped for that file. diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index e5b9c5eb822..288cd87fa90 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -280,6 +280,7 @@ export function createExecTool( logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`); } const configuredHost = defaults?.host ?? "sandbox"; + const sandboxHostConfigured = defaults?.host === "sandbox"; const requestedHost = normalizeExecHost(params.host) ?? null; let host: ExecHost = requestedHost ?? configuredHost; if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) { @@ -307,6 +308,18 @@ export function createExecTool( } const sandbox = host === "sandbox" ? defaults?.sandbox : undefined; + if ( + host === "sandbox" && + !sandbox && + (sandboxHostConfigured || requestedHost === "sandbox") + ) { + throw new Error( + [ + "exec host=sandbox is configured, but sandbox runtime is unavailable for this session.", + 'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or set tools.exec.host to "gateway"/"node".', + ].join("\n"), + ); + } const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd(); let workdir = rawWorkdir; let containerWorkdir = sandbox?.containerWorkdir; diff --git a/src/agents/pi-tools-agent-config.e2e.test.ts b/src/agents/pi-tools-agent-config.e2e.test.ts index cd3f79cb63c..dda8062d34f 100644 --- a/src/agents/pi-tools-agent-config.e2e.test.ts +++ b/src/agents/pi-tools-agent-config.e2e.test.ts @@ -601,6 +601,11 @@ describe("Agent-specific tool filtering", () => { const cfg: OpenClawConfig = { tools: { deny: ["process"], + exec: { + host: "gateway", + security: "full", + ask: "off", + }, }, }; @@ -622,11 +627,30 @@ describe("Agent-specific tool filtering", () => { expect(resultDetails?.status).toBe("completed"); }); + it("fails closed when exec host=sandbox is requested without sandbox runtime", async () => { + const tools = createOpenClawCodingTools({ + config: {}, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main-fail-closed", + agentDir: "/tmp/agent-main-fail-closed", + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + await expect( + execTool!.execute("call-fail-closed", { + command: "echo done", + host: "sandbox", + }), + ).rejects.toThrow("exec host not allowed"); + }); + it("should apply agent-specific exec host defaults over global defaults", async () => { const cfg: OpenClawConfig = { tools: { exec: { host: "sandbox", + security: "full", + ask: "off", }, }, agents: { @@ -654,6 +678,12 @@ describe("Agent-specific tool filtering", () => { }); const mainExecTool = mainTools.find((tool) => tool.name === "exec"); expect(mainExecTool).toBeDefined(); + const mainResult = await mainExecTool!.execute("call-main-default", { + command: "echo done", + yieldMs: 1000, + }); + const mainDetails = mainResult?.details as { status?: string } | undefined; + expect(mainDetails?.status).toBe("completed"); await expect( mainExecTool!.execute("call-main", { command: "echo done", @@ -669,12 +699,18 @@ describe("Agent-specific tool filtering", () => { }); const helperExecTool = helperTools.find((tool) => tool.name === "exec"); expect(helperExecTool).toBeDefined(); - const helperResult = await helperExecTool!.execute("call-helper", { - command: "echo done", - host: "sandbox", - yieldMs: 1000, - }); - const helperDetails = helperResult?.details as { status?: string } | undefined; - expect(helperDetails?.status).toBe("completed"); + await expect( + helperExecTool!.execute("call-helper-default", { + command: "echo done", + yieldMs: 1000, + }), + ).rejects.toThrow("exec host=sandbox is configured"); + await expect( + helperExecTool!.execute("call-helper", { + command: "echo done", + host: "sandbox", + yieldMs: 1000, + }), + ).rejects.toThrow("exec host=sandbox is configured"); }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ff4d3a0d3dd..187e4ffc531 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -349,9 +349,13 @@ export function createOpenClawCodingTools(options?: { return [tool]; }); const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {}; + // Fail-closed baseline: when no sandbox context exists, default exec to gateway + // so we never silently treat "sandbox" as host execution. + const resolvedExecHost = + options?.exec?.host ?? execConfig.host ?? (sandbox ? "sandbox" : "gateway"); const execTool = createExecTool({ ...execDefaults, - host: options?.exec?.host ?? execConfig.host, + host: resolvedExecHost, security: options?.exec?.security ?? execConfig.security, ask: options?.exec?.ask ?? execConfig.ask, node: options?.exec?.node ?? execConfig.node,