From 0ccdf73508ae8b3ad9958d7a767f60a22ba4b5d3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 1 Apr 2026 19:44:15 -0400 Subject: [PATCH] Exec approvals: share host-effective policy snapshot --- .../telegram/src/exec-approval-forwarding.ts | 4 +- .../telegram/src/exec-approvals-handler.ts | 8 +- src/cli/exec-approvals-cli.ts | 24 ++-- src/gateway/server-methods/exec-approval.ts | 13 +- src/infra/exec-approval-forwarder.ts | 6 +- src/infra/exec-approvals-effective.ts | 111 ++++++++++++++---- src/infra/exec-approvals.ts | 18 ++- src/plugin-sdk/approval-runtime.ts | 1 + 8 files changed, 134 insertions(+), 51 deletions(-) diff --git a/extensions/telegram/src/exec-approval-forwarding.ts b/extensions/telegram/src/exec-approval-forwarding.ts index 8d2ea4745d8..f42b4f55ee4 100644 --- a/extensions/telegram/src/exec-approval-forwarding.ts +++ b/extensions/telegram/src/exec-approval-forwarding.ts @@ -1,6 +1,6 @@ import { buildExecApprovalPendingReplyPayload, - resolveExecApprovalAllowedDecisions, + resolveExecApprovalRequestAllowedDecisions, resolveExecApprovalCommandDisplay, type ExecApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; @@ -38,7 +38,7 @@ export function buildTelegramExecApprovalPendingPayload(params: { cwd: params.request.request.cwd ?? undefined, host: params.request.request.host === "node" ? "node" : "gateway", nodeId: params.request.request.nodeId ?? undefined, - allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: params.request.request.ask }), + allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request), expiresAtMs: params.request.expiresAtMs, nowMs: params.nowMs, }); diff --git a/extensions/telegram/src/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts index ce9fbd2a43e..f33e81125e1 100644 --- a/extensions/telegram/src/exec-approvals-handler.ts +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -2,7 +2,7 @@ import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/appr import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createChannelNativeApprovalRuntime, - resolveExecApprovalAllowedDecisions, + resolveExecApprovalRequestAllowedDecisions, type ExecApprovalChannelRuntime, } from "openclaw/plugin-sdk/infra-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; @@ -125,9 +125,9 @@ export class TelegramExecApprovalHandler { cwd: (request as ExecApprovalRequest).request.cwd ?? undefined, host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway", nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined, - allowedDecisions: resolveExecApprovalAllowedDecisions({ - ask: (request as ExecApprovalRequest).request.ask, - }), + allowedDecisions: resolveExecApprovalRequestAllowedDecisions( + (request as ExecApprovalRequest).request, + ), expiresAtMs: request.expiresAtMs, nowMs, } satisfies ExecApprovalPendingReplyParams); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 2211ced116e..7a72d6c4045 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import type { Command } from "commander"; import JSON5 from "json5"; import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveExecPolicyScopeSummary } from "../infra/exec-approvals-effective.js"; +import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; import { readExecApprovalsSnapshot, saveExecApprovals, @@ -31,7 +31,7 @@ type ConfigSnapshotLike = { }; type ApprovalsTargetSource = "gateway" | "node" | "local"; type EffectivePolicyReport = { - scopes: ReturnType; + scopes: ReturnType; note?: string; }; @@ -172,15 +172,16 @@ async function loadConfigForApprovalsTarget(params: { return snapshot.config && typeof snapshot.config === "object" ? snapshot.config : null; } -function collectExecPolicySummaries(params: { cfg: OpenClawConfig; approvals: ExecApprovalsFile }) { - const summaries = [ - resolveExecPolicyScopeSummary({ +function collectExecPolicySnapshots(params: { cfg: OpenClawConfig; approvals: ExecApprovalsFile }) { + const snapshots = [ + resolveExecPolicyScopeSnapshot({ approvals: params.approvals, - execConfig: params.cfg.tools?.exec, + scopeExecConfig: params.cfg.tools?.exec, configPath: "tools.exec", scopeLabel: "tools.exec", }), ]; + const globalExecConfig = params.cfg.tools?.exec; const configAgentIds = new Set((params.cfg.agents?.list ?? []).map((agent) => agent.id)); const approvalAgentIds = Object.keys(params.approvals.agents ?? {}).filter( (agentId) => agentId !== "*" && agentId !== "default", @@ -188,17 +189,18 @@ function collectExecPolicySummaries(params: { cfg: OpenClawConfig; approvals: Ex const agentIds = Array.from(new Set([...configAgentIds, ...approvalAgentIds])).toSorted(); for (const agentId of agentIds) { const agentConfig = params.cfg.agents?.list?.find((agent) => agent.id === agentId); - summaries.push( - resolveExecPolicyScopeSummary({ + snapshots.push( + resolveExecPolicyScopeSnapshot({ approvals: params.approvals, - execConfig: agentConfig?.tools?.exec, + scopeExecConfig: agentConfig?.tools?.exec, + globalExecConfig, configPath: `agents.list.${agentId}.tools.exec`, scopeLabel: `agent:${agentId}`, agentId, }), ); } - return summaries; + return snapshots; } function buildEffectivePolicyReport(params: { @@ -219,7 +221,7 @@ function buildEffectivePolicyReport(params: { }; } return { - scopes: collectExecPolicySummaries({ + scopes: collectExecPolicySnapshots({ cfg: params.cfg, approvals: params.approvals, }), diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 1946d739caf..397895ca18a 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -3,7 +3,8 @@ import { sanitizeExecApprovalDisplayText } from "../../infra/exec-approval-comma import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, - isExecApprovalDecisionAllowed, + resolveExecApprovalAllowedDecisions, + resolveExecApprovalRequestAllowedDecisions, type ExecApprovalDecision, } from "../../infra/exec-approvals.js"; import { @@ -150,6 +151,7 @@ export function createExecApprovalHandlers( host: host || null, security: p.security ?? null, ask: p.ask ?? null, + allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: p.ask ?? null }), agentId: effectiveAgentId ?? null, resolvedPath: p.resolvedPath ?? null, sessionKey: effectiveSessionKey ?? null, @@ -328,13 +330,8 @@ export function createExecApprovalHandlers( } const approvalId = resolvedId.id; const snapshot = manager.getSnapshot(approvalId); - if ( - snapshot && - !isExecApprovalDecisionAllowed({ - decision, - ask: snapshot.request.ask, - }) - ) { + const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot?.request); + if (snapshot && !allowedDecisions.includes(decision)) { respond( false, undefined, diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 5f4edade787..cf8179bb7b8 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -23,7 +23,7 @@ import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-displ import { formatExecApprovalExpiresIn } from "./exec-approval-reply.js"; import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js"; import { - resolveExecApprovalAllowedDecisions, + resolveExecApprovalRequestAllowedDecisions, type ExecApprovalRequest, type ExecApprovalResolved, } from "./exec-approvals.js"; @@ -200,7 +200,7 @@ function formatApprovalCommand(command: string): { inline: boolean; text: string } function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { - const allowedDecisions = resolveExecApprovalAllowedDecisions({ ask: request.request.ask }); + const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(request.request); const decisionText = allowedDecisions.join("|"); const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; const command = formatApprovalCommand( @@ -349,7 +349,7 @@ function buildExecPendingPayload(params: { approvalId: params.request.id, approvalSlug: params.request.id.slice(0, 8), text: buildRequestMessage(params.request, params.nowMs), - allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: params.request.request.ask }), + allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request), }); } diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index 5960c75362c..1eccdfd2255 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -1,5 +1,8 @@ import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { + DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, + resolveExecApprovalAllowedDecisions, + type ExecApprovalDecision, maxAsk, minSecurity, resolveExecApprovalsFromFile, @@ -15,6 +18,10 @@ const REQUESTED_DEFAULT_LABEL = { security: DEFAULT_REQUESTED_SECURITY, ask: DEFAULT_REQUESTED_ASK, } as const; +type ExecPolicyConfig = { + security?: ExecSecurity; + ask?: ExecAsk; +}; export type ExecPolicyFieldSummary = { requested: TValue; @@ -25,7 +32,7 @@ export type ExecPolicyFieldSummary = { note: string; }; -export type ExecPolicyScopeSummary = { +export type ExecPolicyScopeSnapshot = { scopeLabel: string; configPath: string; agentId?: string; @@ -35,16 +42,21 @@ export type ExecPolicyScopeSummary = { effective: ExecSecurity; source: string; }; + allowedDecisions: readonly ExecApprovalDecision[]; }; +export type ExecPolicyScopeSummary = Omit; + +type ExecPolicyRequestedField = "security" | "ask"; + function formatRequestedSource(params: { - path: string; + sourcePath: string; field: "security" | "ask"; - explicit: boolean; + defaultValue: ExecSecurity | ExecAsk; }): string { - return params.explicit - ? `${params.path}.${params.field}` - : `OpenClaw default (${REQUESTED_DEFAULT_LABEL[params.field]})`; + return params.sourcePath === "__default__" + ? `OpenClaw default (${params.defaultValue})` + : `${params.sourcePath}.${params.field}`; } type ExecPolicyField = "security" | "ask" | "askFallback"; @@ -67,6 +79,32 @@ function readExecPolicyField(params: { } } +function resolveRequestedField(params: { + field: ExecPolicyRequestedField; + scopeExecConfig?: ExecPolicyConfig; + globalExecConfig?: ExecPolicyConfig; +}): { value: TValue; sourcePath: string } { + const scopeValue = params.scopeExecConfig?.[params.field]; + if (scopeValue !== undefined) { + return { + value: scopeValue as TValue, + sourcePath: params.field && "scope", + }; + } + const globalValue = params.globalExecConfig?.[params.field]; + if (globalValue !== undefined) { + return { + value: globalValue as TValue, + sourcePath: "tools.exec", + }; + } + const defaultValue = REQUESTED_DEFAULT_LABEL[params.field] as TValue; + return { + value: defaultValue, + sourcePath: "__default__", + }; +} + function resolveHostFieldSource(params: { hostPath: string; agentId?: string; @@ -90,6 +128,9 @@ function resolveHostFieldSource(params: { ) { return `${params.hostPath} defaults.${params.field}`; } + if (params.field === "askFallback") { + return `OpenClaw default (${DEFAULT_EXEC_APPROVAL_ASK_FALLBACK})`; + } return "inherits requested tool policy"; } @@ -118,36 +159,62 @@ function formatHostSource(params: { export function resolveExecPolicyScopeSummary(params: { approvals: ExecApprovalsFile; - execConfig?: { security?: ExecSecurity; ask?: ExecAsk } | undefined; + scopeExecConfig?: ExecPolicyConfig | undefined; + globalExecConfig?: ExecPolicyConfig | undefined; configPath: string; scopeLabel: string; agentId?: string; hostPath?: string; }): ExecPolicyScopeSummary { - const requestedSecurity = params.execConfig?.security ?? DEFAULT_REQUESTED_SECURITY; - const requestedAsk = params.execConfig?.ask ?? DEFAULT_REQUESTED_ASK; + const snapshot = resolveExecPolicyScopeSnapshot(params); + const { allowedDecisions: _allowedDecisions, ...summary } = snapshot; + return summary; +} + +export function resolveExecPolicyScopeSnapshot(params: { + approvals: ExecApprovalsFile; + scopeExecConfig?: ExecPolicyConfig | undefined; + globalExecConfig?: ExecPolicyConfig | undefined; + configPath: string; + scopeLabel: string; + agentId?: string; + hostPath?: string; +}): ExecPolicyScopeSnapshot { + const requestedSecurity = resolveRequestedField({ + field: "security", + scopeExecConfig: params.scopeExecConfig, + globalExecConfig: params.globalExecConfig, + }); + const requestedAsk = resolveRequestedField({ + field: "ask", + scopeExecConfig: params.scopeExecConfig, + globalExecConfig: params.globalExecConfig, + }); const resolved = resolveExecApprovalsFromFile({ file: params.approvals, agentId: params.agentId, overrides: { - security: requestedSecurity, - ask: requestedAsk, + security: requestedSecurity.value, + ask: requestedAsk.value, }, }); const hostPath = params.hostPath ?? DEFAULT_HOST_PATH; - const effectiveSecurity = minSecurity(requestedSecurity, resolved.agent.security); + const effectiveSecurity = minSecurity(requestedSecurity.value, resolved.agent.security); const effectiveAsk = - resolved.agent.ask === "off" ? "off" : maxAsk(requestedAsk, resolved.agent.ask); + resolved.agent.ask === "off" ? "off" : maxAsk(requestedAsk.value, resolved.agent.ask); return { scopeLabel: params.scopeLabel, configPath: params.configPath, ...(params.agentId ? { agentId: params.agentId } : {}), security: { - requested: requestedSecurity, + requested: requestedSecurity.value, requestedSource: formatRequestedSource({ - path: params.configPath, + sourcePath: + requestedSecurity.sourcePath === "scope" + ? params.configPath + : requestedSecurity.sourcePath, field: "security", - explicit: params.execConfig?.security !== undefined, + defaultValue: DEFAULT_REQUESTED_SECURITY, }), host: resolved.agent.security, hostSource: formatHostSource({ @@ -158,16 +225,17 @@ export function resolveExecPolicyScopeSummary(params: { }), effective: effectiveSecurity, note: - effectiveSecurity === requestedSecurity + effectiveSecurity === requestedSecurity.value ? "requested security applies" : "stricter host security wins", }, ask: { - requested: requestedAsk, + requested: requestedAsk.value, requestedSource: formatRequestedSource({ - path: params.configPath, + sourcePath: + requestedAsk.sourcePath === "scope" ? params.configPath : requestedAsk.sourcePath, field: "ask", - explicit: params.execConfig?.ask !== undefined, + defaultValue: DEFAULT_REQUESTED_ASK, }), host: resolved.agent.ask, hostSource: formatHostSource({ @@ -178,7 +246,7 @@ export function resolveExecPolicyScopeSummary(params: { }), effective: effectiveAsk, note: resolveAskNote({ - requestedAsk, + requestedAsk: requestedAsk.value, hostAsk: resolved.agent.ask, effectiveAsk, }), @@ -192,5 +260,6 @@ export function resolveExecPolicyScopeSummary(params: { approvals: params.approvals, }), }, + allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: effectiveAsk }), }; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 2bc2602ee46..800b295fe7f 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -81,6 +81,7 @@ export type ExecApprovalRequestPayload = { host?: string | null; security?: string | null; ask?: string | null; + allowedDecisions?: readonly ExecApprovalDecision[]; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; @@ -159,7 +160,7 @@ export const DEFAULT_EXEC_APPROVAL_TIMEOUT_MS = 1_800_000; const DEFAULT_SECURITY: ExecSecurity = "deny"; const DEFAULT_ASK: ExecAsk = "on-miss"; -const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny"; +export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "deny"; const DEFAULT_AUTO_ALLOW_SKILLS = false; const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock"; const DEFAULT_FILE = "~/.openclaw/exec-approvals.json"; @@ -469,7 +470,7 @@ export function resolveExecApprovalsFromFile(params: { const wildcard = file.agents?.["*"] ?? {}; const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK; - const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_ASK_FALLBACK; + const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_EXEC_APPROVAL_ASK_FALLBACK; const fallbackAutoAllowSkills = params.overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS; const resolvedDefaults: Required = { security: normalizeSecurity(defaults.security, fallbackSecurity), @@ -675,6 +676,19 @@ export function resolveExecApprovalAllowedDecisions(params?: { return DEFAULT_EXEC_APPROVAL_DECISIONS; } +export function resolveExecApprovalRequestAllowedDecisions(params?: { + ask?: string | null; + allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null; +}): readonly ExecApprovalDecision[] { + const explicit = Array.isArray(params?.allowedDecisions) + ? params.allowedDecisions.filter( + (decision): decision is ExecApprovalDecision => + decision === "allow-once" || decision === "allow-always" || decision === "deny", + ) + : []; + return explicit.length > 0 ? explicit : resolveExecApprovalAllowedDecisions({ ask: params?.ask }); +} + export function isExecApprovalDecisionAllowed(params: { decision: ExecApprovalDecision; ask?: string | null; diff --git a/src/plugin-sdk/approval-runtime.ts b/src/plugin-sdk/approval-runtime.ts index 3e3db6aed0d..f6eb8adcfdc 100644 --- a/src/plugin-sdk/approval-runtime.ts +++ b/src/plugin-sdk/approval-runtime.ts @@ -3,6 +3,7 @@ export { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, resolveExecApprovalAllowedDecisions, + resolveExecApprovalRequestAllowedDecisions, type ExecApprovalDecision, type ExecApprovalRequest, type ExecApprovalRequestPayload,