diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index d71c521f6f10..d685af4163e9 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -144,9 +144,15 @@ when set at the narrower session or agent scope.
### `exec.ask`
- - `off` - never prompt.
- - `on-miss` - prompt only when the allowlist does not match.
- - `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
+ Configured ask policy for host exec. Controls the baseline approval
+ prompt behavior from `tools.exec.ask` and host approvals defaults. The
+ per-call `ask` tool parameter (see [Exec tool](/tools/exec#parameters))
+ can only harden that baseline, and channel-origin model calls ignore it
+ when the effective host ask is `off`.
+
+- `off` - never prompt.
+- `on-miss` - prompt only when the allowlist does not match.
+- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index b6650dbe36a0..72e14f1def6a 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -52,7 +52,11 @@ force `security=full` only when the operator explicitly grants elevated access.
-Approval prompt behavior for `gateway` / `node` execution.
+The baseline ask mode comes from `tools.exec.ask` and host approvals.
+For channel-origin model calls, per-call `ask` is ignored when the
+effective host ask is `off`; otherwise it can only harden to a stricter
+mode. Trusted internal/API callers that construct exec tools with an
+explicit `ask` value are unchanged.
diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts
index 1896209659be..94db06305293 100644
--- a/src/agents/bash-tools.exec.security-floor.test.ts
+++ b/src/agents/bash-tools.exec.security-floor.test.ts
@@ -13,6 +13,24 @@ vi.mock("./tools/gateway.js", () => ({
readGatewayCallOptions: vi.fn(() => ({})),
}));
+function installAllowlistedGogFixture(root: string): string {
+ const binDir = path.join(root, "bin");
+ const openclawDir = path.join(root, ".openclaw");
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(openclawDir, { recursive: true });
+ const gogPath = path.join(binDir, "gog");
+ fs.writeFileSync(gogPath, "#!/bin/sh\nprintf 'gog-ok %s\\n' \"$*\"\n", { mode: 0o755 });
+ fs.writeFileSync(
+ path.join(openclawDir, "exec-approvals.json"),
+ `${JSON.stringify({
+ version: 1,
+ defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" },
+ agents: { "*": { allowlist: [{ pattern: gogPath }] } },
+ })}\n`,
+ );
+ return binDir;
+}
+
describe("exec security floor", () => {
let envSnapshot: ReturnType;
let tempRoot: string | undefined;
@@ -88,6 +106,52 @@ describe("exec security floor", () => {
).rejects.toThrow(/exec denied: allowlist miss/i);
});
+ it("ignores model-supplied ask overrides when configured ask is off", async () => {
+ const root = tempRoot ?? os.tmpdir();
+ const binDir = installAllowlistedGogFixture(root);
+ const tool = createExecTool({
+ host: "gateway",
+ security: "allowlist",
+ ask: "off",
+ safeBins: [],
+ pathPrepend: [binDir],
+ messageProvider: "telegram",
+ currentChannelId: "telegram:12345",
+ accountId: "default",
+ });
+
+ const result = await tool.execute("call-model-ask-ignored", {
+ command: "gog tasks add tasklist --title test",
+ ask: "always",
+ });
+
+ expect(result.details.status).toBe("completed");
+ expect((result.content[0] as { text?: string }).text ?? "").toContain(
+ "gog-ok tasks add tasklist --title test",
+ );
+ expect(callGatewayTool).not.toHaveBeenCalled();
+ });
+
+ it("honors per-call ask hardening for trusted callers without messageProvider", async () => {
+ const root = tempRoot ?? os.tmpdir();
+ const binDir = installAllowlistedGogFixture(root);
+ const tool = createExecTool({
+ host: "gateway",
+ security: "allowlist",
+ ask: "off",
+ safeBins: [],
+ pathPrepend: [binDir],
+ });
+
+ const result = await tool.execute("call-trusted-ask-always", {
+ command: "gog tasks add tasklist --title test",
+ ask: "always",
+ });
+
+ expect(callGatewayTool).toHaveBeenCalled();
+ expect(result.details.status).toBe("approval-pending");
+ });
+
it("ignores model-supplied deny security when configured security is allowlist", async () => {
const tool = createExecTool({
security: "allowlist",
diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts
index 27ab70067076..858da4260394 100644
--- a/src/agents/bash-tools.exec.ts
+++ b/src/agents/bash-tools.exec.ts
@@ -16,6 +16,7 @@ import {
loadExecApprovals,
maxAsk,
minSecurity,
+ normalizeExecAsk,
requireValidExecTarget,
resolveExecApprovalsFromFile,
resolveExecModePolicy,
@@ -56,7 +57,6 @@ import {
type ExecProcessOutcome,
applyPathPrepend,
applyShellPath,
- normalizeExecAsk,
normalizePathPrepend,
resolveExecTarget,
resolveApprovalRunningNoticeMs,
@@ -1613,7 +1613,8 @@ export function createExecTool(
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset.
const requestedAsk = normalizeExecAsk(params.ask);
const hostAsk = maxAsk(modePolicy.ask, approvalPolicy?.ask ?? modePolicy.ask);
- let ask = maxAsk(hostAsk, requestedAsk ?? hostAsk);
+ const trustedAsk = defaults?.messageProvider && hostAsk === "off" ? undefined : requestedAsk;
+ let ask = maxAsk(hostAsk, trustedAsk ?? hostAsk);
const bypassApprovals =
elevatedRequested &&
elevatedMode === "full" &&
diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts
index a0e9e8089add..701349d25516 100644
--- a/src/agents/bash-tools.schemas.ts
+++ b/src/agents/bash-tools.schemas.ts
@@ -40,7 +40,8 @@ export const execSchema = Type.Object({
),
ask: Type.Optional(
Type.String({
- description: "Exec ask mode (off|on-miss|always).",
+ description:
+ "Baseline ask comes from tools.exec.ask and host approvals; channel-origin calls ignore per-call ask when effective host ask is off.",
}),
),
node: Type.Optional(