mirror of https://github.com/openclaw/openclaw.git
fix(exec): default implicit target to auto
This commit is contained in:
parent
d014f173f1
commit
276ccd2583
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue