From 5662a9cdfc2b6dcbec04242e7c6a2dd948b0a246 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 04:53:26 +0000 Subject: [PATCH] fix: honor tools.exec ask/security in approvals --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 1 + .../bash-tools.exec.approval-id.test.ts | 21 +++++++++++++++++++ src/agents/bash-tools.exec.ts | 7 ++----- src/cli/nodes-cli/register.invoke.ts | 2 +- src/node-host/runner.ts | 21 ++++++++++++++++--- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 935167db3..d2c39b31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.clawd.bot - Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. +- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 2ab96695c..79a58aa47 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -12,6 +12,7 @@ Exec approvals are the **companion app / node host guardrail** for letting a san commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands are allowed only when policy + allowlist + (optional) user approval all agree. Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals). +Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used. If the companion app UI is **not available**, any request that requires a prompt is resolved by the **ask fallback** (default: deny). diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 16a198b5c..6606ae008 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -129,4 +129,25 @@ describe("exec approvals", () => { expect(calls).toContain("node.invoke"); expect(calls).not.toContain("exec.approval.request"); }); + + it("honors ask=off for elevated gateway exec without prompting", async () => { + const { callGatewayTool } = await import("./tools/gateway.js"); + const calls: string[] = []; + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + calls.push(method); + return { ok: true }; + }); + + const { createExecTool } = await import("./bash-tools.exec.js"); + const tool = createExecTool({ + ask: "off", + security: "full", + approvalRunningNoticeMs: 0, + elevated: { enabled: true, allowed: true, defaultLevel: "ask" }, + }); + + const result = await tool.execute("call3", { command: "echo ok", elevated: true }); + expect(result.details.status).toBe("completed"); + expect(calls).not.toContain("exec.approval.request"); + }); }); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index ee801b840..c4848dcdb 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -838,10 +838,7 @@ export function createExecTool( applyPathPrepend(env, defaultPathPrepend); if (host === "node") { - const approvals = resolveExecApprovals( - agentId, - host === "node" ? { security: "allowlist" } : undefined, - ); + const approvals = resolveExecApprovals(agentId, { security, ask }); const hostSecurity = minSecurity(security, approvals.agent.security); const hostAsk = maxAsk(ask, approvals.agent.ask); const askFallback = approvals.agent.askFallback; @@ -1112,7 +1109,7 @@ export function createExecTool( } if (host === "gateway" && !bypassApprovals) { - const approvals = resolveExecApprovals(agentId, { security: "allowlist" }); + const approvals = resolveExecApprovals(agentId, { security, ask }); const hostSecurity = minSecurity(security, approvals.agent.security); const hostAsk = maxAsk(ask, approvals.agent.ask); const askFallback = approvals.agent.askFallback; diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 9a9ff2834..a5dc01df0 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -248,7 +248,7 @@ export function registerNodesInvokeCommands(nodes: Command) { const approvals = resolveExecApprovalsFromFile({ file: approvalsFile as ExecApprovalsFile, agentId, - overrides: { security: "allowlist" }, + overrides: { security, ask }, }); const hostSecurity = minSecurity(security, approvals.agent.security); const hostAsk = maxAsk(ask, approvals.agent.ask); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 151659713..f76065883 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -18,6 +18,8 @@ import { readExecApprovalsSnapshot, resolveExecApprovalsSocketPath, saveExecApprovals, + type ExecAsk, + type ExecSecurity, type ExecApprovalsFile, type ExecAllowlistEntry, type ExecCommandSegment, @@ -110,6 +112,14 @@ type RunResult = { truncated: boolean; }; +function resolveExecSecurity(value?: string): ExecSecurity { + return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist"; +} + +function resolveExecAsk(value?: string): ExecAsk { + return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss"; +} + type ExecEventPayload = { sessionKey: string; runId: string; @@ -794,15 +804,20 @@ async function handleInvoke( const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : ""; const cmdText = rawCommand || formatCommand(argv); const agentId = params.agentId?.trim() || undefined; - const approvals = resolveExecApprovals(agentId, { security: "allowlist" }); + const cfg = loadConfig(); + const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; + const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security); + const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); + const approvals = resolveExecApprovals(agentId, { + security: configuredSecurity, + ask: configuredAsk, + }); const security = approvals.agent.security; const ask = approvals.agent.ask; const autoAllowSkills = approvals.agent.autoAllowSkills; const sessionKey = params.sessionKey?.trim() || "node"; const runId = params.runId?.trim() || crypto.randomUUID(); const env = sanitizeEnv(params.env ?? undefined); - const cfg = loadConfig(); - const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); const bins = autoAllowSkills ? await skillBins.current() : new Set(); let analysisOk = false;