From 276ccd2583a90743a53bbed78c0f5bc9ffdf5500 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 05:59:08 +0900 Subject: [PATCH] fix(exec): default implicit target to auto --- CHANGELOG.md | 1 + SECURITY.md | 2 +- docs/gateway/security/index.md | 4 +- docs/tools/exec.md | 12 ++-- docs/tools/slash-commands.md | 2 +- src/agents/bash-tools.exec-runtime.test.ts | 22 +++++++ src/agents/bash-tools.exec-runtime.ts | 61 ++++++++++++++++++- src/agents/bash-tools.exec-types.ts | 4 +- src/agents/bash-tools.exec.path.test.ts | 7 +-- src/agents/bash-tools.exec.ts | 33 ++++------ src/agents/pi-tools-agent-config.test.ts | 33 ++++++---- src/agents/test-helpers/fast-tool-stubs.ts | 13 ++-- ...rrent-verbose-level-verbose-has-no.test.ts | 4 +- src/auto-reply/reply.directive.parse.test.ts | 4 +- .../reply/directive-handling.impl.ts | 36 ++++++++--- .../reply/directive-handling.parse.ts | 4 +- src/auto-reply/reply/exec/directive.ts | 21 +++---- src/auto-reply/reply/reply-utils.test.ts | 4 +- src/config/schema.base.generated.ts | 8 +-- src/config/schema.help.ts | 2 +- src/config/schema.labels.ts | 2 +- src/config/types.tools.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 2 +- src/gateway/sessions-patch.test.ts | 4 +- src/gateway/sessions-patch.ts | 13 +--- src/infra/exec-approvals-policy.test.ts | 11 ++++ src/infra/exec-approvals.ts | 9 +++ src/security/audit.ts | 4 +- 28 files changed, 216 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2dc992f09..3c358512b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 1000bae9b73..cd8006ac6d3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 97ffa3804cf..a20361a00f8 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 agent’s 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. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 4498ef99535..ef51fef2769 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -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 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 2221ddce68d..88c873c7a72 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -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= security= ask= node=` (send `/exec` to show current) +- `/exec host= security= ask= node=` (send `/exec` to show current) - `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/bash ` (host-only; alias for `! `; requires `commands.bash: true` + `tools.elevated` allowlists) diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index c1d311fa85f..8973661ae3b 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -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(); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 15481ff5776..b8721d12e45 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -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(); } diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 4d5341050dc..7417da612ac 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -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; diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 4f3a7e39eee..9fa68d24443 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -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/); }); }); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index d556e9b724d..7010be52272 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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"), ); } diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 258ded2466c..ad7bbaadaca 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -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 () => { diff --git a/src/agents/test-helpers/fast-tool-stubs.ts b/src/agents/test-helpers/fast-tool-stubs.ts index 7a9c9eb9ec5..114abb2144f 100644 --- a/src/agents/test-helpers/fast-tool-stubs.ts +++ b/src/agents/test-helpers/fast-tool-stubs.ts @@ -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(); + return { + ...mod, + resolvePluginTools: () => [], + getPluginToolMeta: () => undefined, + }; +}); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 7ae62074cca..df0c31a5673 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -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=.", + "Options: host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=.", ); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index 6d0b484511c..be51089200e 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -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"); diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index bfbf204ef54..a54794b09fb 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -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=", + `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=", ), }; } diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts index cacada54053..1834cc0b770 100644 --- a/src/auto-reply/reply/directive-handling.parse.ts +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -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; diff --git a/src/auto-reply/reply/exec/directive.ts b/src/auto-reply/reply/exec/directive.ts index abdb19e9b6b..620d8f0ddf2 100644 --- a/src/auto-reply/reply/exec/directive.ts +++ b/src/auto-reply/reply/exec/directive.ts @@ -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; } diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index e0c8abed541..18c5ea7cf19 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -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(); }); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 9cf5474c5a9..33ad213e82f 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 79013bd90c9..4bfeac063b6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -312,7 +312,7 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6fd71eb8df7..098723a9d81 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -180,7 +180,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 2bb63000ec4..c6959f3cd63 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -226,8 +226,8 @@ export function parseToolsBySenderTypedKey( export type GroupToolPolicyBySenderConfig = Record; 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). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 13a4e78cb7b..6e94391974d 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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(), diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 478e360ecaf..c5533e6f7a0 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -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"); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 18b542302f6..a0c3cb3553b 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -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; } diff --git a/src/infra/exec-approvals-policy.test.ts b/src/infra/exec-approvals-policy.test.ts index 26a706a47b9..861906de017 100644 --- a/src/infra/exec-approvals-policy.test.ts +++ b/src/infra/exec-approvals-policy.test.ts @@ -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" }, diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 5d169ab3e4c..1a78b732faa 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -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") { diff --git a/src/security/audit.ts b/src/security/audit.ts index e5895c14ab5..858bca19541 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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(),