Skip to content

Commit ba735d0

Browse files
authored
Exec approvals: unify effective policy reporting and actions (#59283)
Merged via squash. Prepared head SHA: d579b97 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent dc66c36 commit ba735d0

36 files changed

Lines changed: 1618 additions & 112 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828
- Matrix/runtime: resolve the verification/bootstrap runtime from a distinct packaged Matrix entry so global npm installs stop failing on crypto bootstrap with missing-module or recursive runtime alias errors. (#59249) Thanks @gumadeiras.
2929
- Matrix/streaming: preserve ordered block flushes before tool, message, and agent boundaries, add explicit `channels.matrix.blockStreaming` opt-in so Matrix `streaming: "off"` stays final-only by default, and move MiniMax plain-text final handling into the MiniMax provider runtime instead of the shared core heuristic. (#59266) thanks @gumadeiras
3030
- Agents/compaction: resolve compaction wait before final reply/channel flush completion so slow end-of-run delivery drains no longer delay compaction completion. (#59308) thanks @gumadeiras
31+
- Exec approvals: align approval UX, effective-policy reporting, and `allow-always` availability with the host policy so CLI, doctor, and approval surfaces explain the real host-effective decision path. (#59283) Thanks @gumadeiras.
3132

3233
## 2026.4.2
3334

docs/cli/approvals.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ openclaw approvals get --node <id|name|ip>
2424
openclaw approvals get --gateway
2525
```
2626

27+
`openclaw approvals get` now shows the effective exec policy for local, gateway, and node targets:
28+
29+
- requested `tools.exec` policy
30+
- host approvals-file policy
31+
- effective result after precedence rules are applied
32+
33+
Precedence is intentional:
34+
35+
- the host approvals file is the enforceable source of truth
36+
- requested `tools.exec` policy can narrow or broaden intent, but the effective result is still derived from the host rules
37+
- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime
38+
- if gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed
39+
2740
## Replace approvals from a file
2841

2942
```bash

docs/tools/exec-approvals.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; i
1717
Host exec also uses the local approvals state on that machine. A host-local
1818
`ask: "always"` in `~/.openclaw/exec-approvals.json` keeps prompting even if
1919
session or config defaults request `ask: "on-miss"`.
20+
Use `openclaw approvals get`, `openclaw approvals get --gateway`, or
21+
`openclaw approvals get --node <id|name|ip>` to inspect the requested policy,
22+
host policy sources, and the effective result.
2023

2124
If the companion app UI is **not available**, any request that requires a prompt is
2225
resolved by the **ask fallback** (default: deny).

docs/tools/slash-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Text + native (when enabled):
8080
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
8181
- `/tasks` (list background tasks for the current session; shows active and recent task details with agent-local fallback counts)
8282
- `/allowlist` (list/add/remove allowlist entries)
83-
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
83+
- `/approve <id> <decision>` (resolve exec approval prompts; use the pending approval message for the available decisions)
8484
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
8585
- `/btw <question>` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw))
8686
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)

extensions/discord/src/monitor/exec-approvals.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,31 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
10901090
}),
10911091
);
10921092
});
1093+
1094+
it("omits allow-always when exec approvals disallow it", async () => {
1095+
const handler = createHandler({
1096+
enabled: true,
1097+
approvers: ["123"],
1098+
target: "dm",
1099+
});
1100+
1101+
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
1102+
1103+
await handler.handleApprovalRequested(
1104+
createRequest({
1105+
ask: "always",
1106+
allowedDecisions: ["allow-once", "deny"],
1107+
}),
1108+
);
1109+
1110+
const dmCall = mockRestPost.mock.calls.find(
1111+
([route]) => route === Routes.channelMessages("dm-1"),
1112+
);
1113+
const payload = JSON.stringify(dmCall?.[1]?.body);
1114+
expect(payload).toContain("Allow Once");
1115+
expect(payload).toContain("Deny");
1116+
expect(payload).not.toContain("Allow Always");
1117+
});
10931118
});
10941119

10951120
describe("DiscordExecApprovalHandler resolve routing", () => {

extensions/discord/src/monitor/exec-approvals.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,36 @@ class ExecApprovalActionButton extends Button {
190190
}
191191

192192
class ExecApprovalActionRow extends Row<Button> {
193-
constructor(approvalId: string) {
193+
constructor(params: {
194+
approvalId: string;
195+
ask?: string | null;
196+
allowedDecisions?: readonly ExecApprovalDecision[];
197+
}) {
194198
super([
195-
...buildExecApprovalActionDescriptors({ approvalCommandId: approvalId }).map(
196-
(descriptor) => new ExecApprovalActionButton({ approvalId, descriptor }),
199+
...buildExecApprovalActionDescriptors({
200+
approvalCommandId: params.approvalId,
201+
ask: params.ask,
202+
allowedDecisions: params.allowedDecisions,
203+
}).map(
204+
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
197205
),
198206
]);
199207
}
200208
}
201209

210+
function createApprovalActionRow(request: ApprovalRequest): Row<Button> {
211+
if (isPluginApprovalRequest(request)) {
212+
return new ExecApprovalActionRow({
213+
approvalId: request.id,
214+
});
215+
}
216+
return new ExecApprovalActionRow({
217+
approvalId: request.id,
218+
ask: request.request.ask,
219+
allowedDecisions: request.request.allowedDecisions,
220+
});
221+
}
222+
202223
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
203224
const lines: string[] = [];
204225
if (request.request.cwd) {
@@ -466,7 +487,7 @@ export class DiscordExecApprovalHandler {
466487
isConfigured: () => Boolean(this.opts.config.enabled && this.getApprovers().length > 0),
467488
shouldHandle: (request) => this.shouldHandle(request),
468489
buildPendingContent: ({ request }) => {
469-
const actionRow = new ExecApprovalActionRow(request.id);
490+
const actionRow = createApprovalActionRow(request);
470491
const container = isPluginApprovalRequest(request)
471492
? createPluginApprovalRequestContainer({
472493
request,

extensions/slack/src/monitor/exec-approvals.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ describe("SlackExecApprovalHandler", () => {
130130
);
131131
});
132132

133+
it("omits allow-always when exec approvals disallow it", async () => {
134+
const app = buildApp();
135+
const handler = new SlackExecApprovalHandler({
136+
app,
137+
accountId: "default",
138+
config: buildConfig("dm").channels!.slack!.execApprovals!,
139+
cfg: buildConfig("dm"),
140+
});
141+
142+
await handler.handleApprovalRequested(
143+
buildRequest({
144+
ask: "always",
145+
allowedDecisions: ["allow-once", "deny"],
146+
}),
147+
);
148+
149+
const dmCall = sendMessageSlackMock.mock.calls.find(([to]) => to === "user:U123APPROVER");
150+
const blocks = dmCall?.[2]?.blocks as Array<Record<string, unknown>> | undefined;
151+
const actionsBlock = blocks?.find((block) => block.type === "actions");
152+
const buttons = Array.isArray(actionsBlock?.elements) ? actionsBlock.elements : [];
153+
const buttonTexts = buttons.map((button) =>
154+
typeof button === "object" && button && typeof button.text === "object" && button.text
155+
? String((button.text as { text?: unknown }).text ?? "")
156+
: "",
157+
);
158+
159+
expect(buttonTexts).toContain("Allow Once");
160+
expect(buttonTexts).toContain("Deny");
161+
expect(buttonTexts).not.toContain("Allow Always");
162+
});
163+
133164
it("updates the pending approval card in place after resolution", async () => {
134165
const app = buildApp();
135166
const update = app.client.chat.update as ReturnType<typeof vi.fn>;

extensions/slack/src/monitor/exec-approvals.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createChannelNativeApprovalRuntime,
77
getExecApprovalApproverDmNoticeText,
88
resolveExecApprovalCommandDisplay,
9+
resolveExecApprovalRequestAllowedDecisions,
910
type ExecApprovalChannelRuntime,
1011
type ExecApprovalDecision,
1112
type ExecApprovalRequest,
@@ -99,6 +100,8 @@ function buildSlackPendingApprovalBlocks(request: ExecApprovalRequest): SlackBlo
99100
text: "",
100101
interactive: buildApprovalInteractiveReply({
101102
approvalId: request.id,
103+
ask: request.request.ask,
104+
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(request.request),
102105
}),
103106
}) ?? [];
104107
return [

extensions/telegram/src/exec-approval-forwarding.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
buildExecApprovalPendingReplyPayload,
3+
resolveExecApprovalRequestAllowedDecisions,
34
resolveExecApprovalCommandDisplay,
45
type ExecApprovalRequest,
56
} from "openclaw/plugin-sdk/approval-runtime";
@@ -37,6 +38,7 @@ export function buildTelegramExecApprovalPendingPayload(params: {
3738
cwd: params.request.request.cwd ?? undefined,
3839
host: params.request.request.host === "node" ? "node" : "gateway",
3940
nodeId: params.request.request.nodeId ?? undefined,
41+
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
4042
expiresAtMs: params.request.expiresAtMs,
4143
nowMs: params.nowMs,
4244
});

extensions/telegram/src/exec-approvals-handler.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,50 @@ describe("TelegramExecApprovalHandler", () => {
114114
);
115115
});
116116

117+
it("hides allow-always actions when ask=always", async () => {
118+
const cfg = {
119+
channels: {
120+
telegram: {
121+
execApprovals: {
122+
enabled: true,
123+
approvers: ["8460800771"],
124+
target: "channel",
125+
},
126+
},
127+
},
128+
} as OpenClawConfig;
129+
const { handler, sendMessage } = createHandler(cfg);
130+
131+
await handler.handleRequested({
132+
...baseRequest,
133+
request: {
134+
...baseRequest.request,
135+
ask: "always",
136+
},
137+
});
138+
139+
expect(sendMessage).toHaveBeenCalledWith(
140+
"-1003841603622",
141+
expect.not.stringContaining("allow-always"),
142+
expect.objectContaining({
143+
buttons: [
144+
[
145+
{
146+
text: "Allow Once",
147+
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
148+
style: "success",
149+
},
150+
{
151+
text: "Deny",
152+
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
153+
style: "danger",
154+
},
155+
],
156+
],
157+
}),
158+
);
159+
});
160+
117161
it("falls back to approver DMs when channel routing is unavailable", async () => {
118162
const cfg = {
119163
channels: {

0 commit comments

Comments
 (0)