Exec approvals: share host-effective policy snapshot

This commit is contained in:
Gustavo Madeira Santana
2026-04-01 19:44:15 -04:00
parent a465504ab1
commit 0ccdf73508
8 changed files with 134 additions and 51 deletions

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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<typeof collectExecPolicySummaries>;
scopes: ReturnType<typeof collectExecPolicySnapshots>;
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,
}),

View File

@@ -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,

View File

@@ -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),
});
}

View File

@@ -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<TValue extends ExecSecurity | ExecAsk> = {
requested: TValue;
@@ -25,7 +32,7 @@ export type ExecPolicyFieldSummary<TValue extends ExecSecurity | ExecAsk> = {
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<ExecPolicyScopeSnapshot, "allowedDecisions">;
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<TValue extends ExecSecurity | ExecAsk>(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<ExecSecurity>({
field: "security",
scopeExecConfig: params.scopeExecConfig,
globalExecConfig: params.globalExecConfig,
});
const requestedAsk = resolveRequestedField<ExecAsk>({
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 }),
};
}

View File

@@ -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<ExecApprovalsDefaults> = {
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;

View File

@@ -3,6 +3,7 @@
export {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalDecision,
type ExecApprovalRequest,
type ExecApprovalRequestPayload,