Exec: fail closed when sandbox host is unavailable

This commit is contained in:
Brian Mendonca
2026-02-22 01:49:10 -07:00
committed by Peter Steinberger
parent 5a0032de3e
commit c76a47cce2
4 changed files with 65 additions and 12 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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");
});
});

View File

@@ -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,