fix(exec): default implicit target to auto

This commit is contained in:
Peter Steinberger 2026-03-30 05:59:08 +09:00
parent d014f173f1
commit 276ccd2583
No known key found for this signature in database
28 changed files with 216 additions and 110 deletions

View File

@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Docker/setup: force BuildKit for local image builds (including sandbox image builds) so `./docker-setup.sh` no longer fails on `RUN --mount=...` when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.
- Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84.
- Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev.
- Exec/runtime: default implicit exec to `host=auto`, resolve that target to sandbox only when a sandbox runtime exists, keep explicit `host=sandbox` fail-closed without sandbox, and show `/exec` effective host state in runtime status/docs.
- Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.
- Exec/node: stop gateway-side workdir fallback from rewriting explicit `host=node` cwd values to the gateway filesystem, so remote node exec approval and runs keep using the intended node-local directory. (#50961) Thanks @openperf.
- Plugins/ClawHub: sanitize temporary archive filenames for scoped package names and slash-containing skill slugs so `openclaw plugins install @scope/name` no longer fails with `ENOENT` during archive download. (#56452) Thanks @soimy.

View File

@ -101,7 +101,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
- `tools.exec.host` defaults to `sandbox` only when sandbox runtime is active for the session; otherwise implicit exec runs on the gateway host.
- `tools.exec.host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.

View File

@ -191,7 +191,7 @@ If more than one person can DM your bot:
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
- **Plugins** (extensions exist without an explicit allowlist).
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns because matching is exact command-name only (for example `system.run`) and does not inspect shell text; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
- **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which fails closed because no sandbox runtime is available).
- **Runtime expectation drift** (for example assuming implicit exec still means `sandbox` when `tools.exec.host` now defaults to `auto`, or explicitly setting `tools.exec.host="sandbox"` while sandbox mode is off).
- **Model hygiene** (warn when configured models look legacy; not a hard block).
If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
@ -534,7 +534,7 @@ Even with strong system prompts, **prompt injection is not solved**. System prom
- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agents reachable filesystem.
- Note: sandboxing is opt-in. If sandbox mode is off, explicit `host=sandbox` fails closed because no sandbox runtime is available. Implicit exec still runs on the gateway host; set `host=gateway` if you want that behavior to be explicit in config.
- Note: sandboxing is opt-in. If sandbox mode is off, implicit `host=auto` resolves to the gateway host. Explicit `host=sandbox` still fails closed because no sandbox runtime is available. Set `host=gateway` if you want that behavior to be explicit in config.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- If you allowlist interpreters (`python`, `node`, `ruby`, `perl`, `php`, `lua`, `osascript`), enable `tools.exec.strictInlineEval` so inline eval forms still need explicit approval.
- **Model choice matters:** older/smaller/legacy models are significantly less robust against prompt injection and tool misuse. For tool-enabled agents, use the strongest latest-generation, instruction-hardened model available.

View File

@ -21,7 +21,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `host` (`sandbox | gateway | node`): where to execute
- `host` (`auto | sandbox | gateway | node`): where to execute
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
@ -29,7 +29,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
Notes:
- `host` defaults to `sandbox` when sandbox runtime is active for the session; otherwise it defaults to `gateway`.
- `host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
- `elevated` forces `host=gateway`; it is only available when elevated access is enabled for the current session/provider.
- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host).
@ -41,8 +41,8 @@ Notes:
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
- Important: sandboxing is **off by default**. If sandboxing is off and exec resolves to
`host=sandbox` (including the implicit default), exec fails closed instead of silently
- Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto`
resolves to `gateway`. Explicit `host=sandbox` still 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
@ -52,7 +52,7 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single “running” notice when an approval-gated exec runs longer than this (0 disables).
- `tools.exec.host` (default: runtime-aware: `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
@ -104,7 +104,7 @@ Send `/exec` with no arguments to show the current values.
Example:
```
/exec host=gateway security=allowlist ask=on-miss node=mac-1
/exec host=auto security=allowlist ask=on-miss node=mac-1
```
## Authorization model

View File

@ -117,7 +117,7 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/exec host=<auto|sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)

View File

@ -8,6 +8,7 @@ let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExe
let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode;
let emitExecSystemEvent: typeof import("./bash-tools.exec-runtime.js").emitExecSystemEvent;
let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").formatExecFailureReason;
let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget;
describe("detectCursorKeyMode", () => {
beforeEach(async () => {
@ -41,6 +42,27 @@ describe("detectCursorKeyMode", () => {
});
});
describe("resolveExecTarget", () => {
beforeEach(async () => {
({ resolveExecTarget } = await import("./bash-tools.exec-runtime.js"));
});
it("treats auto as a default strategy rather than a host allowlist", () => {
expect(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "node",
elevatedRequested: false,
sandboxAvailable: false,
}),
).toMatchObject({
configuredTarget: "auto",
selectedTarget: "node",
effectiveHost: "node",
});
});
});
describe("emitExecSystemEvent", () => {
beforeEach(async () => {
vi.resetModules();

View File

@ -1,7 +1,7 @@
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { type ExecHost } from "../infra/exec-approvals.js";
import { type ExecHost, type ExecTarget } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
@ -15,6 +15,7 @@ export {
normalizeExecAsk,
normalizeExecHost,
normalizeExecSecurity,
normalizeExecTarget,
} from "../infra/exec-approvals.js";
import { logWarn } from "../logger.js";
import type { ManagedRun } from "../process/supervisor/index.js";
@ -143,7 +144,7 @@ export const execSchema = Type.Object({
),
host: Type.Optional(
Type.String({
description: "Exec host (sandbox|gateway|node).",
description: "Exec host/target (auto|sandbox|gateway|node).",
}),
),
security: Type.Optional(
@ -206,6 +207,62 @@ export function renderExecHostLabel(host: ExecHost) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
}
export function renderExecTargetLabel(target: ExecTarget) {
return target === "auto" ? "auto" : renderExecHostLabel(target);
}
export function isRequestedExecTargetAllowed(params: {
configuredTarget: ExecTarget;
requestedTarget: ExecTarget;
}) {
if (params.requestedTarget === params.configuredTarget) {
return true;
}
if (params.configuredTarget === "auto") {
return true;
}
return false;
}
export function resolveExecTarget(params: {
configuredTarget?: ExecTarget;
requestedTarget?: ExecTarget | null;
elevatedRequested: boolean;
sandboxAvailable: boolean;
}) {
const configuredTarget = params.configuredTarget ?? "auto";
const requestedTarget = params.requestedTarget ?? null;
if (params.elevatedRequested) {
return {
configuredTarget,
requestedTarget,
selectedTarget: "gateway" as const,
effectiveHost: "gateway" as const,
};
}
if (
requestedTarget &&
!isRequestedExecTargetAllowed({
configuredTarget,
requestedTarget,
})
) {
throw new Error(
`exec host not allowed (requested ${renderExecTargetLabel(requestedTarget)}; ` +
`configure tools.exec.host=${renderExecTargetLabel(configuredTarget)} to allow).`,
);
}
const selectedTarget = requestedTarget ?? configuredTarget;
const effectiveHost =
selectedTarget === "auto" ? (params.sandboxAvailable ? "sandbox" : "gateway") : selectedTarget;
return {
configuredTarget,
requestedTarget,
selectedTarget,
effectiveHost,
};
}
export function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}

View File

@ -1,9 +1,9 @@
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../infra/exec-approvals.js";
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecToolDefaults = {
host?: ExecHost;
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
node?: string;

View File

@ -251,14 +251,13 @@ describe("exec host env validation", () => {
}
});
it("defaults implicit exec host to gateway when sandbox runtime is unavailable", async () => {
it("routes implicit auto host to gateway when sandbox runtime is unavailable", async () => {
const tool = createExecTool({ security: "full", ask: "off" });
const result = await tool.execute("call1", {
command: "echo ok",
});
const output = normalizeText(result.content.find((c) => c.type === "text")?.text);
expect(output).toContain("ok");
expect(normalizeText(result.content.find((c) => c.type === "text")?.text)).toBe("ok");
});
it("fails closed when sandbox host is explicitly configured without sandbox runtime", async () => {
@ -268,6 +267,6 @@ describe("exec host env validation", () => {
tool.execute("call1", {
command: "echo ok",
}),
).rejects.toThrow(/sandbox runtime is unavailable/);
).rejects.toThrow(/requires a sandbox runtime/);
});
});

View File

@ -21,10 +21,10 @@ import {
applyPathPrepend,
applyShellPath,
normalizeExecAsk,
normalizeExecHost,
normalizeExecSecurity,
normalizeExecTarget,
normalizePathPrepend,
renderExecHostLabel,
resolveExecTarget,
resolveApprovalRunningNoticeMs,
runExecProcess,
execSchema,
@ -329,20 +329,13 @@ export function createExecTool(
if (elevatedRequested) {
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
}
// Keep the implicit host aligned with the active runtime: host-first unless a sandbox
// runtime is actually available for this session, while still honoring explicit overrides.
const configuredHost = defaults?.host ?? (defaults?.sandbox ? "sandbox" : "gateway");
const requestedHost = normalizeExecHost(params.host) ?? null;
let host: ExecHost = requestedHost ?? configuredHost;
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
throw new Error(
`exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
`configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
);
}
if (elevatedRequested) {
host = "gateway";
}
const target = resolveExecTarget({
configuredTarget: defaults?.host,
requestedTarget: normalizeExecTarget(params.host),
elevatedRequested,
sandboxAvailable: Boolean(defaults?.sandbox),
});
const host: ExecHost = target.effectiveHost;
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
@ -360,13 +353,11 @@ export function createExecTool(
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const sandboxHostConfigured = defaults?.host === "sandbox" || requestedHost === "sandbox";
// Never fall through to direct host exec when sandbox was selected explicitly.
if (host === "sandbox" && !sandbox && sandboxHostConfigured) {
if (target.selectedTarget === "sandbox" && !sandbox) {
throw new Error(
[
"exec host resolved to sandbox, 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".',
"exec host=sandbox requires a sandbox runtime for this session.",
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or use host=auto/gateway/node.',
].join("\n"),
);
}

View File

@ -121,12 +121,12 @@ describe("Agent-specific tool filtering", () => {
}
function createExecHostDefaultsConfig(
agents: Array<{ id: string; execHost?: "gateway" | "sandbox" }>,
agents: Array<{ id: string; execHost?: "auto" | "gateway" | "sandbox" }>,
): OpenClawConfig {
return {
tools: {
exec: {
host: "sandbox",
host: "auto",
security: "full",
ask: "off",
},
@ -658,9 +658,16 @@ describe("Agent-specific tool filtering", () => {
expect(resultDetails?.status).toBe("completed");
});
it("defaults implicit exec host to gateway when sandbox runtime is unavailable", async () => {
it("routes implicit auto exec to gateway without a sandbox runtime", async () => {
const tools = createOpenClawCodingTools({
config: {},
config: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
},
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test-main-implicit-gateway",
agentDir: "/tmp/agent-main-implicit-gateway",
@ -668,7 +675,7 @@ describe("Agent-specific tool filtering", () => {
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool!.execute("call-implicit-gateway-default", {
const result = await execTool!.execute("call-implicit-auto-default", {
command: "echo done",
});
const resultDetails = result?.details as { status?: string } | undefined;
@ -689,7 +696,7 @@ describe("Agent-specific tool filtering", () => {
command: "echo done",
host: "sandbox",
}),
).rejects.toThrow("sandbox runtime is unavailable");
).rejects.toThrow("requires a sandbox runtime");
});
it("should apply agent-specific exec host defaults over global defaults", async () => {
@ -727,19 +734,19 @@ describe("Agent-specific tool filtering", () => {
});
const helperExecTool = helperTools.find((tool) => tool.name === "exec");
expect(helperExecTool).toBeDefined();
await expect(
helperExecTool!.execute("call-helper-default", {
command: "echo done",
yieldMs: 1000,
}),
).rejects.toThrow("sandbox runtime is unavailable");
const helperResult = await helperExecTool!.execute("call-helper-default", {
command: "echo done",
yieldMs: 1000,
});
const helperDetails = helperResult?.details as { status?: string } | undefined;
expect(helperDetails?.status).toBe("completed");
await expect(
helperExecTool!.execute("call-helper", {
command: "echo done",
host: "sandbox",
yieldMs: 1000,
}),
).rejects.toThrow("sandbox runtime is unavailable");
).rejects.toThrow("requires a sandbox runtime");
});
it("applies explicit agentId exec defaults when sessionKey is opaque", async () => {

View File

@ -24,8 +24,11 @@ vi.mock("../tools/web-tools.js", () => ({
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
copyPluginToolMeta: (_source: unknown, target: unknown) => target,
}));
vi.mock("../../plugins/tools.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../../plugins/tools.js")>();
return {
...mod,
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
};
});

View File

@ -171,10 +171,10 @@ describe("directive behavior", () => {
},
});
expect(execText).toContain(
"Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.",
"Current exec defaults: host=gateway, effective=gateway, security=allowlist, ask=always, node=mac-1.",
);
expect(execText).toContain(
"Options: host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>.",
"Options: host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>.",
);
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});

View File

@ -138,10 +138,10 @@ describe("directive parsing", () => {
it("matches exec directive with options", () => {
const res = extractExecDirective(
"please /exec host=gateway security=allowlist ask=on-miss node=mac-mini now",
"please /exec host=auto security=allowlist ask=on-miss node=mac-mini now",
);
expect(res.hasDirective).toBe(true);
expect(res.execHost).toBe("gateway");
expect(res.execHost).toBe("auto");
expect(res.execSecurity).toBe("allowlist");
expect(res.execAsk).toBe("on-miss");
expect(res.execNode).toBe("mac-mini");

View File

@ -3,12 +3,13 @@ import {
resolveAgentDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { renderExecTargetLabel, resolveExecTarget } from "../../agents/bash-tools.exec-runtime.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { requestLiveSessionModelSwitch } from "../../agents/live-model-switch.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { OpenClawConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../../infra/exec-approvals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@ -36,17 +37,31 @@ function resolveExecDefaults(params: {
cfg: OpenClawConfig;
sessionEntry?: SessionEntry;
agentId?: string;
}): { host: ExecHost; security: ExecSecurity; ask: ExecAsk; node?: string } {
sandboxAvailable: boolean;
}): {
host: ExecTarget;
effectiveHost: ExecHost;
security: ExecSecurity;
ask: ExecAsk;
node?: string;
} {
const globalExec = params.cfg.tools?.exec;
const agentExec = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.exec
: undefined;
const host =
(params.sessionEntry?.execHost as ExecTarget | undefined) ??
(agentExec?.host as ExecTarget | undefined) ??
(globalExec?.host as ExecTarget | undefined) ??
"auto";
const resolved = resolveExecTarget({
configuredTarget: host,
elevatedRequested: false,
sandboxAvailable: params.sandboxAvailable,
});
return {
host:
(params.sessionEntry?.execHost as ExecHost | undefined) ??
(agentExec?.host as ExecHost | undefined) ??
(globalExec?.host as ExecHost | undefined) ??
"sandbox",
host,
effectiveHost: resolved.effectiveHost,
security:
(params.sessionEntry?.execSecurity as ExecSecurity | undefined) ??
(agentExec?.security as ExecSecurity | undefined) ??
@ -251,7 +266,7 @@ export async function handleDirectiveOnly(
if (directives.hasExecDirective) {
if (directives.invalidExecHost) {
return {
text: `Unrecognized exec host "${directives.rawExecHost ?? ""}". Valid hosts: sandbox, gateway, node.`,
text: `Unrecognized exec host "${directives.rawExecHost ?? ""}". Valid hosts: auto, sandbox, gateway, node.`,
};
}
if (directives.invalidExecSecurity) {
@ -274,12 +289,13 @@ export async function handleDirectiveOnly(
cfg: params.cfg,
sessionEntry,
agentId: activeAgentId,
sandboxAvailable: runtimeIsSandboxed,
});
const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)";
return {
text: withOptions(
`Current exec defaults: host=${execDefaults.host}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`,
"host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>",
`Current exec defaults: host=${renderExecTargetLabel(execDefaults.host)}, effective=${execDefaults.effectiveHost}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`,
"host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>",
),
};
}

View File

@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import type { ExecAsk, ExecSecurity, ExecTarget } from "../../infra/exec-approvals.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
@ -34,7 +34,7 @@ export type InlineDirectives = {
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasExecDirective: boolean;
execHost?: ExecHost;
execHost?: ExecTarget;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;

View File

@ -1,10 +1,15 @@
import type { ExecAsk, ExecHost, ExecSecurity } from "../../../infra/exec-approvals.js";
import {
type ExecAsk,
type ExecSecurity,
type ExecTarget,
normalizeExecTarget,
} from "../../../infra/exec-approvals.js";
import { skipDirectiveArgPrefix, takeDirectiveToken } from "../directive-parsing.js";
type ExecDirectiveParse = {
cleaned: string;
hasDirective: boolean;
execHost?: ExecHost;
execHost?: ExecTarget;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;
@ -19,14 +24,6 @@ type ExecDirectiveParse = {
invalidNode: boolean;
};
function normalizeExecHost(value?: string): ExecHost | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return undefined;
}
function normalizeExecSecurity(value?: string): ExecSecurity | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
@ -52,7 +49,7 @@ function parseExecDirectiveArgs(raw: string): Omit<
const len = raw.length;
let i = skipDirectiveArgPrefix(raw);
let consumed = i;
let execHost: ExecHost | undefined;
let execHost: ExecTarget | undefined;
let execSecurity: ExecSecurity | undefined;
let execAsk: ExecAsk | undefined;
let execNode: string | undefined;
@ -99,7 +96,7 @@ function parseExecDirectiveArgs(raw: string): Omit<
const { key, value } = parsed;
if (key === "host") {
rawExecHost = value;
execHost = normalizeExecHost(value);
execHost = normalizeExecTarget(value) ?? undefined;
if (!execHost) {
invalidHost = true;
}

View File

@ -285,14 +285,14 @@ describe("normalizeReplyPayload", () => {
it("leaves complex Options lines as plain text", () => {
const result = normalizeReplyPayload(
{
text: "ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe(
"ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
"ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
);
expect(result!.interactive).toBeUndefined();
});

View File

@ -4623,7 +4623,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
properties: {
host: {
type: "string",
enum: ["sandbox", "gateway", "node"],
enum: ["auto", "sandbox", "gateway", "node"],
},
security: {
type: "string",
@ -7205,7 +7205,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
properties: {
host: {
type: "string",
enum: ["sandbox", "gateway", "node"],
enum: ["auto", "sandbox", "gateway", "node"],
},
security: {
type: "string",
@ -12623,8 +12623,8 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
tags: ["tools"],
},
"tools.exec.host": {
label: "Exec Host",
help: "Selects execution host strategy for shell commands, typically controlling local vs delegated execution environment. Use the safest host mode that still satisfies your automation requirements.",
label: "Exec Target",
help: 'Selects execution target strategy for shell commands. Use "auto" for runtime-aware behavior (sandbox when available, otherwise gateway), or pin sandbox/gateway/node explicitly when you need a fixed surface.',
tags: ["tools"],
},
"tools.exec.security": {

View File

@ -312,7 +312,7 @@ export const FIELD_HELP: Record<string, string> = {
"tools.exec":
"Exec-tool policy grouping for shell execution host, security mode, approval behavior, and runtime bindings. Keep conservative defaults in production and tighten elevated execution paths.",
"tools.exec.host":
"Selects execution host strategy for shell commands, typically controlling local vs delegated execution environment. Use the safest host mode that still satisfies your automation requirements.",
'Selects execution target strategy for shell commands. Use "auto" for runtime-aware behavior (sandbox when available, otherwise gateway), or pin sandbox/gateway/node explicitly when you need a fixed surface.',
"tools.exec.security":
"Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.",
"tools.exec.ask":

View File

@ -180,7 +180,7 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.exec.notifyOnExit": "Exec Notify On Exit",
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
"tools.exec.host": "Exec Host",
"tools.exec.host": "Exec Target",
"tools.exec.security": "Exec Security",
"tools.exec.ask": "Exec Ask",
"tools.exec.node": "Exec Node Binding",

View File

@ -226,8 +226,8 @@ export function parseToolsBySenderTypedKey(
export type GroupToolPolicyBySenderConfig = Record<string, GroupToolPolicyConfig>;
export type ExecToolConfig = {
/** Exec host routing (default: sandbox). */
host?: "sandbox" | "gateway" | "node";
/** Exec host routing (default: auto). */
host?: "auto" | "sandbox" | "gateway" | "node";
/** Exec security mode (default: deny). */
security?: "deny" | "allowlist" | "full";
/** Exec ask mode (default: on-miss). */

View File

@ -432,7 +432,7 @@ const ToolExecSafeBinProfileSchema = z
.strict();
const ToolExecBaseShape = {
host: z.enum(["sandbox", "gateway", "node"]).optional(),
host: z.enum(["auto", "sandbox", "gateway", "node"]).optional(),
security: z.enum(["deny", "allowlist", "full"]).optional(),
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),

View File

@ -338,7 +338,7 @@ describe("gateway sessions patch", () => {
await runPatch({
patch: {
key: MAIN_SESSION_KEY,
execHost: " NODE ",
execHost: " AUTO ",
execSecurity: " ALLOWLIST ",
execAsk: " ON-MISS ",
execNode: " worker-1 ",
@ -347,7 +347,7 @@ describe("gateway sessions patch", () => {
},
}),
);
expect(entry.execHost).toBe("node");
expect(entry.execHost).toBe("auto");
expect(entry.execSecurity).toBe("allowlist");
expect(entry.execAsk).toBe("on-miss");
expect(entry.execNode).toBe("worker-1");

View File

@ -19,6 +19,7 @@ import {
} from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { normalizeExecTarget } from "../infra/exec-approvals.js";
import {
isAcpSessionKey,
isSubagentSessionKey,
@ -40,14 +41,6 @@ function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
}
function normalizeExecHost(raw: string): "sandbox" | "gateway" | "node" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return undefined;
}
function normalizeExecSecurity(raw: string): "deny" | "allowlist" | "full" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
@ -326,9 +319,9 @@ export async function applySessionsPatchToStore(params: {
if (raw === null) {
delete next.execHost;
} else if (raw !== undefined) {
const normalized = normalizeExecHost(String(raw));
const normalized = normalizeExecTarget(String(raw)) ?? undefined;
if (!normalized) {
return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
return invalid('invalid execHost (use "auto"|"sandbox"|"gateway"|"node")');
}
next.execHost = normalized;
}

View File

@ -4,6 +4,7 @@ import {
minSecurity,
normalizeExecAsk,
normalizeExecHost,
normalizeExecTarget,
normalizeExecSecurity,
requiresExecApproval,
} from "./exec-approvals.js";
@ -18,6 +19,16 @@ describe("exec approvals policy helpers", () => {
expect(normalizeExecHost(raw)).toBe(expected);
});
it.each([
{ raw: " auto ", expected: "auto" },
{ raw: " gateway ", expected: "gateway" },
{ raw: "NODE", expected: "node" },
{ raw: "", expected: null },
{ raw: "ssh", expected: null },
])("normalizes exec target value %j", ({ raw, expected }) => {
expect(normalizeExecTarget(raw)).toBe(expected);
});
it.each([
{ raw: " allowlist ", expected: "allowlist" },
{ raw: "FULL", expected: "full" },

View File

@ -8,6 +8,7 @@ export * from "./exec-approvals-analysis.js";
export * from "./exec-approvals-allowlist.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecTarget = "auto" | ExecHost;
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
@ -19,6 +20,14 @@ export function normalizeExecHost(value?: string | null): ExecHost | null {
return null;
}
export function normalizeExecTarget(value?: string | null): ExecTarget | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "auto") {
return normalized;
}
return normalizeExecHost(normalized);
}
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {

View File

@ -945,7 +945,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
{
id: DEFAULT_AGENT_ID,
security: cfg.tools?.exec?.security ?? "deny",
host: cfg.tools?.exec?.host ?? "sandbox",
host: cfg.tools?.exec?.host ?? "auto",
},
...agents
.filter(
@ -955,7 +955,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
.map((entry) => ({
id: entry.id,
security: entry.tools?.exec?.security ?? cfg.tools?.exec?.security ?? "deny",
host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "sandbox",
host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "auto",
})),
].map((entry) => [entry.id, entry] as const),
).values(),