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(