From ad8b839aa70babcefe42b96a82b6ecd5e4a3a0c9 Mon Sep 17 00:00:00 2001 From: Seb Slight <19554889+sebslight@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:48:52 -0500 Subject: [PATCH] Exec approvals: render forwarded commands in monospace (#11937) * fix(exec-approvals): format forwarded commands as code * fix(exec-approvals): place fenced command blocks on new line (#11937) (thanks @sebslight) --- CHANGELOG.md | 1 + src/infra/exec-approval-forwarder.test.ts | 94 +++++++++++++++++++++++ src/infra/exec-approval-forwarder.ts | 20 ++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8204b9cea4..d860720dc61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Exec approvals: format forwarded command text as inline/fenced monospace for safer approval scanning across channels. (#11917) - Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot. - Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 60f8ad1485d..fa0c6c536fa 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -17,6 +17,13 @@ afterEach(() => { vi.useRealTimers(); }); +function getFirstDeliveryText(deliver: ReturnType): string { + const firstCall = deliver.mock.calls[0]?.[0] as + | { payloads?: Array<{ text?: string }> } + | undefined; + return firstCall?.payloads?.[0]?.text ?? ""; +} + describe("exec approval forwarder", () => { it("forwards to session target and resolves", async () => { vi.useFakeTimers(); @@ -73,4 +80,91 @@ describe("exec approval forwarder", () => { await vi.runAllTimersAsync(); expect(deliver).toHaveBeenCalledTimes(2); }); + + it("formats single-line commands as inline code", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested(baseRequest); + + expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`"); + }); + + it("formats complex commands as fenced code blocks", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + command: "echo `uname`\necho done", + }, + }); + + expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```"); + }); + + it("uses a longer fence when command already contains triple backticks", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + command: "echo ```danger```", + }, + }); + + expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````"); + }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 8ce0748cc56..0dd657b25c0 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -115,9 +115,27 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string { return [channel, target.to, accountId, threadId].join(":"); } +function formatApprovalCommand(command: string): { inline: boolean; text: string } { + if (!command.includes("\n") && !command.includes("`")) { + return { inline: true, text: `\`${command}\`` }; + } + + let fence = "```"; + while (command.includes(fence)) { + fence += "`"; + } + return { inline: false, text: `${fence}\n${command}\n${fence}` }; +} + function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; - lines.push(`Command: ${request.request.command}`); + const command = formatApprovalCommand(request.request.command); + if (command.inline) { + lines.push(`Command: ${command.text}`); + } else { + lines.push("Command:"); + lines.push(command.text); + } if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); }