fix(exec): resolve remote approval regressions (#58792)

* fix(exec): restore remote approval policy defaults

* fix(exec): handle headless cron approval conflicts

* fix(exec): make allow-always durable

* fix(exec): persist exact-command shell trust

* fix(doctor): match host exec fallback

* fix(exec): preserve blocked and inline approval state

* Doctor: surface allow-always ask bypass

* Doctor: match effective exec policy

* Exec: match node durable command text

* Exec: tighten durable approval security

* Exec: restore owner approver fallback

* Config: refresh Slack approval metadata

---------

Co-authored-by: scoootscooob <zhentongfan@gmail.com>
This commit is contained in:
Vincent Koc
2026-04-01 18:07:20 +09:00
committed by GitHub
parent 4ceb01f9ed
commit 2d53ffdec1
34 changed files with 1609 additions and 226 deletions

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn());
const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/exec-approvals.js", () => ({
evaluateShellAllowlist: vi.fn(() => ({
allowlistMatches: [],
analysisOk: true,
allowlistSatisfied: true,
segments: [{ resolution: null, argv: ["echo", "ok"] }],
segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
})),
hasDurableExecApproval: vi.fn(() => true),
buildEnforcedShellCommand: vi.fn(() => ({
ok: false,
reason: "segment execution plan unavailable",
})),
requiresExecApproval: vi.fn(() => false),
recordAllowlistUse: vi.fn(),
resolveApprovalAuditCandidatePath: vi.fn(() => null),
resolveAllowAlwaysPatterns: vi.fn(() => []),
addAllowlistEntry: vi.fn(),
addDurableCommandApproval: vi.fn(),
}));
vi.mock("./bash-tools.exec-approval-request.js", () => ({
buildExecApprovalRequesterContext: vi.fn(() => ({})),
buildExecApprovalTurnSourceContext: vi.fn(() => ({})),
registerExecApprovalRequestForHostOrThrow: vi.fn(async () => undefined),
}));
vi.mock("./bash-tools.exec-host-shared.js", () => ({
resolveExecHostApprovalContext: vi.fn(() => ({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "allowlist",
hostAsk: "off",
askFallback: "deny",
})),
buildDefaultExecApprovalRequestArgs: vi.fn(() => ({})),
buildHeadlessExecApprovalDeniedMessage: vi.fn(() => "denied"),
buildExecApprovalFollowupTarget: vi.fn(() => null),
buildExecApprovalPendingToolResult: buildExecApprovalPendingToolResultMock,
createExecApprovalDecisionState: vi.fn(() => ({
baseDecision: { timedOut: false },
approvedByAsk: false,
deniedReason: "approval-required",
})),
createAndRegisterDefaultExecApprovalRequest: createAndRegisterDefaultExecApprovalRequestMock,
resolveApprovalDecisionOrUndefined: vi.fn(async () => undefined),
sendExecApprovalFollowupResult: vi.fn(async () => undefined),
shouldResolveExecApprovalUnavailableInline: vi.fn(() => false),
}));
vi.mock("./bash-tools.exec-runtime.js", () => ({
DEFAULT_NOTIFY_TAIL_CHARS: 1000,
createApprovalSlug: vi.fn(() => "slug"),
normalizeNotifyOutput: vi.fn((value) => value),
runExecProcess: vi.fn(),
}));
vi.mock("./bash-process-registry.js", () => ({
markBackgrounded: vi.fn(),
tail: vi.fn((value) => value),
}));
vi.mock("../infra/exec-inline-eval.js", () => ({
describeInterpreterInlineEval: vi.fn(() => "python -c"),
detectInterpreterInlineEvalArgv: vi.fn(() => null),
}));
vi.mock("../infra/exec-obfuscation-detect.js", () => ({
detectCommandObfuscation: vi.fn(() => ({
detected: false,
reasons: [],
matchedPatterns: [],
})),
}));
let processGatewayAllowlist: typeof import("./bash-tools.exec-host-gateway.js").processGatewayAllowlist;
describe("processGatewayAllowlist", () => {
beforeEach(async () => {
vi.resetModules();
buildExecApprovalPendingToolResultMock.mockReset();
buildExecApprovalPendingToolResultMock.mockReturnValue({
details: { status: "approval-pending" },
content: [],
});
createAndRegisterDefaultExecApprovalRequestMock.mockReset();
createAndRegisterDefaultExecApprovalRequestMock.mockResolvedValue({
approvalId: "req-1",
approvalSlug: "slug-1",
warningText: "",
expiresAtMs: Date.now() + 60_000,
preResolvedDecision: null,
initiatingSurface: "origin",
sentApproverDms: false,
unavailableReason: null,
});
({ processGatewayAllowlist } = await import("./bash-tools.exec-host-gateway.js"));
});
it("still requires approval when allowlist execution plan is unavailable despite durable trust", async () => {
const result = await processGatewayAllowlist({
command: "echo ok",
workdir: process.cwd(),
env: process.env as Record<string, string>,
pty: false,
defaultTimeoutSec: 30,
security: "allowlist",
ask: "off",
safeBins: new Set(),
safeBinProfiles: {},
warnings: [],
approvalRunningNoticeMs: 0,
maxOutput: 1000,
pendingMaxOutput: 1000,
});
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
expect(result.pendingResult?.details.status).toBe("approval-pending");
});
});

View File

@@ -1,10 +1,12 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
addDurableCommandApproval,
addAllowlistEntry,
type ExecAsk,
type ExecSecurity,
buildEnforcedShellCommand,
evaluateShellAllowlist,
hasDurableExecApproval,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
requiresExecApproval,
@@ -25,6 +27,7 @@ import {
} from "./bash-tools.exec-approval-request.js";
import {
buildDefaultExecApprovalRequestArgs,
buildHeadlessExecApprovalDeniedMessage,
buildExecApprovalFollowupTarget,
buildExecApprovalPendingToolResult,
createExecApprovalDecisionState,
@@ -32,6 +35,7 @@ import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
sendExecApprovalFollowupResult,
shouldResolveExecApprovalUnavailableInline,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_NOTIFY_TAIL_CHARS,
@@ -54,6 +58,7 @@ export type ProcessGatewayAllowlistParams = {
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
strictInlineEval?: boolean;
trigger?: string;
agentId?: string;
sessionKey?: string;
turnSourceChannel?: string;
@@ -71,6 +76,7 @@ export type ProcessGatewayAllowlistParams = {
export type ProcessGatewayAllowlistResult = {
execCommandOverride?: string;
allowWithoutEnforcedCommand?: boolean;
pendingResult?: AgentToolResult<ExecToolDetails>;
};
@@ -97,6 +103,12 @@ export async function processGatewayAllowlist(
const analysisOk = allowlistEval.analysisOk;
const allowlistSatisfied =
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
const durableApprovalSatisfied = hasDurableExecApproval({
analysisOk,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
allowlist: approvals.allowlist,
commandText: params.command,
});
const inlineEvalHit =
params.strictInlineEval === true
? (allowlistEval.segments
@@ -113,6 +125,7 @@ export async function processGatewayAllowlist(
);
}
let enforcedCommand: string | undefined;
let allowlistPlanUnavailableReason: string | null = null;
if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) {
const enforced = buildEnforcedShellCommand({
command: params.command,
@@ -120,9 +133,10 @@ export async function processGatewayAllowlist(
platform: process.platform,
});
if (!enforced.ok || !enforced.command) {
throw new Error(`exec denied: allowlist execution plan unavailable (${enforced.reason})`);
allowlistPlanUnavailableReason = enforced.reason ?? "unsupported platform";
} else {
enforcedCommand = enforced.command;
}
enforcedCommand = enforced.command;
}
const obfuscation = detectCommandObfuscation(params.command);
if (obfuscation.detected) {
@@ -148,13 +162,21 @@ export async function processGatewayAllowlist(
const requiresHeredocApproval =
hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment;
const requiresInlineEvalApproval = inlineEvalHit !== null;
const requiresAllowlistPlanApproval =
hostSecurity === "allowlist" &&
analysisOk &&
allowlistSatisfied &&
!enforcedCommand &&
allowlistPlanUnavailableReason !== null;
const requiresAsk =
requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
}) ||
requiresAllowlistPlanApproval ||
requiresHeredocApproval ||
requiresInlineEvalApproval ||
obfuscation.detected;
@@ -163,6 +185,11 @@ export async function processGatewayAllowlist(
"Warning: heredoc execution requires explicit approval in allowlist mode.",
);
}
if (requiresAllowlistPlanApproval) {
params.warnings.push(
`Warning: allowlist auto-execution is unavailable on ${process.platform}; explicit approval is required.`,
);
}
if (requiresAsk) {
const requestArgs = buildDefaultExecApprovalRequestArgs({
@@ -204,6 +231,42 @@ export async function processGatewayAllowlist(
...requestArgs,
register: registerGatewayApproval,
});
if (
shouldResolveExecApprovalUnavailableInline({
trigger: params.trigger,
unavailableReason,
preResolvedDecision,
})
) {
const { approvedByAsk, deniedReason } = createExecApprovalDecisionState({
decision: preResolvedDecision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
if (deniedReason || !approvedByAsk) {
throw new Error(
buildHeadlessExecApprovalDeniedMessage({
trigger: params.trigger,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
askFallback,
}),
);
}
recordMatchedAllowlistUse(
resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
),
);
return {
execCommandOverride: enforcedCommand,
allowWithoutEnforcedCommand: enforcedCommand === undefined,
};
}
const resolvedPath = resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
@@ -255,7 +318,7 @@ export async function processGatewayAllowlist(
approvedByAsk = true;
} else if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist" && !requiresInlineEvalApproval) {
if (!requiresInlineEvalApproval) {
const patterns = resolveAllowAlwaysPatterns({
segments: allowlistEval.segments,
cwd: params.workdir,
@@ -265,13 +328,23 @@ export async function processGatewayAllowlist(
});
for (const pattern of patterns) {
if (pattern) {
addAllowlistEntry(approvals.file, params.agentId, pattern);
addAllowlistEntry(approvals.file, params.agentId, pattern, {
source: "allow-always",
});
}
}
if (patterns.length === 0) {
addDurableCommandApproval(approvals.file, params.agentId, params.command);
}
}
}
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
if (
hostSecurity === "allowlist" &&
(!analysisOk || !allowlistSatisfied) &&
!approvedByAsk &&
!durableApprovalSatisfied
) {
deniedReason = deniedReason ?? "allowlist-miss";
}

View File

@@ -5,6 +5,7 @@ import {
type ExecAsk,
type ExecSecurity,
evaluateShellAllowlist,
hasDurableExecApproval,
requiresExecApproval,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
@@ -43,6 +44,7 @@ export type ExecuteNodeHostCommandParams = {
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
trigger?: string;
agentId?: string;
security: ExecSecurity;
ask: ExecAsk;
@@ -134,6 +136,7 @@ export async function executeNodeHostCommand(
});
let analysisOk = baseAllowlistEval.analysisOk;
let allowlistSatisfied = false;
let durableApprovalSatisfied = false;
const inlineEvalHit =
params.strictInlineEval === true
? (baseAllowlistEval.segments
@@ -149,7 +152,7 @@ export async function executeNodeHostCommand(
)}.`,
);
}
if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) {
if ((hostAsk === "always" || hostSecurity === "allowlist") && analysisOk) {
try {
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
"exec.approvals.node.get",
@@ -176,6 +179,12 @@ export async function executeNodeHostCommand(
platform: nodeInfo?.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
});
durableApprovalSatisfied = hasDurableExecApproval({
analysisOk: allowlistEval.analysisOk,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
allowlist: resolved.allowlist,
commandText: runRawCommand,
});
allowlistSatisfied = allowlistEval.allowlistSatisfied;
analysisOk = allowlistEval.analysisOk;
}
@@ -196,6 +205,7 @@ export async function executeNodeHostCommand(
security: hostSecurity,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
}) ||
inlineEvalHit !== null ||
obfuscation.detected;
@@ -232,6 +242,9 @@ export async function executeNodeHostCommand(
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
let inlineApprovedByAsk = false;
let inlineApprovalDecision: "allow-once" | "allow-always" | null = null;
let inlineApprovalId: string | undefined;
if (requiresAsk) {
const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({
warnings: params.warnings,
@@ -269,119 +282,149 @@ export async function executeNodeHostCommand(
...requestArgs,
register: registerNodeApproval,
});
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
void (async () => {
const decision = await execHostShared.resolveApprovalDecisionOrUndefined({
approvalId,
if (
execHostShared.shouldResolveExecApprovalUnavailableInline({
trigger: params.trigger,
unavailableReason,
preResolvedDecision,
onFailure: () =>
void execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
),
});
if (decision === undefined) {
return;
}
const {
baseDecision,
approvedByAsk: initialApprovedByAsk,
deniedReason: initialDeniedReason,
} = execHostShared.createExecApprovalDecisionState({
decision,
})
) {
const { approvedByAsk, deniedReason } = execHostShared.createExecApprovalDecisionState({
decision: preResolvedDecision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = initialApprovedByAsk;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason = initialDeniedReason;
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
approvalDecision = "allow-once";
} else if (decision === "allow-once") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (decision === "allow-always") {
approvedByAsk = true;
approvalDecision = "allow-always";
}
if (deniedReason) {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
);
return;
}
try {
const raw = await callGatewayTool<{
payload?: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
};
}>(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
);
const payload =
raw?.payload && typeof raw.payload === "object"
? (raw.payload as {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
})
: {};
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
} catch {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
if (deniedReason || !approvedByAsk) {
throw new Error(
execHostShared.buildHeadlessExecApprovalDeniedMessage({
trigger: params.trigger,
host: "node",
security: hostSecurity,
ask: hostAsk,
askFallback,
}),
);
}
})();
inlineApprovedByAsk = approvedByAsk;
inlineApprovalDecision = approvedByAsk ? "allow-once" : null;
inlineApprovalId = approvalId;
} else {
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
return execHostShared.buildExecApprovalPendingToolResult({
host: "node",
command: params.command,
cwd: params.workdir,
warningText,
approvalId,
approvalSlug,
expiresAtMs,
initiatingSurface,
sentApproverDms,
unavailableReason,
nodeId,
});
void (async () => {
const decision = await execHostShared.resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
void execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
),
});
if (decision === undefined) {
return;
}
const {
baseDecision,
approvedByAsk: initialApprovedByAsk,
deniedReason: initialDeniedReason,
} = execHostShared.createExecApprovalDecisionState({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = initialApprovedByAsk;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason = initialDeniedReason;
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
approvalDecision = "allow-once";
} else if (decision === "allow-once") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (decision === "allow-always") {
approvedByAsk = true;
approvalDecision = "allow-always";
}
if (deniedReason) {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
);
return;
}
try {
const raw = await callGatewayTool<{
payload?: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
};
}>(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
);
const payload =
raw?.payload && typeof raw.payload === "object"
? (raw.payload as {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
})
: {};
const combined = [payload.stdout, payload.stderr, payload.error]
.filter(Boolean)
.join("\n");
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
} catch {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
);
}
})();
return execHostShared.buildExecApprovalPendingToolResult({
host: "node",
command: params.command,
cwd: params.workdir,
warningText,
approvalId,
approvalSlug,
expiresAtMs,
initiatingSurface,
sentApproverDms,
unavailableReason,
nodeId,
});
}
}
const startedAt = Date.now();
const raw = await callGatewayTool(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(false, null),
buildInvokeParams(inlineApprovedByAsk, inlineApprovalDecision, inlineApprovalId),
);
const payload =
raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined;

View File

@@ -65,6 +65,10 @@ export type ExecApprovalUnavailableReason =
| "initiating-platform-disabled"
| "initiating-platform-unsupported";
function isHeadlessExecTrigger(trigger?: string): boolean {
return trigger === "cron";
}
export type RegisteredExecApprovalRequestContext = {
approvalId: string;
approvalSlug: string;
@@ -340,6 +344,38 @@ export function createExecApprovalDecisionState(params: {
};
}
export function shouldResolveExecApprovalUnavailableInline(params: {
trigger?: string;
unavailableReason: ExecApprovalUnavailableReason | null;
preResolvedDecision: string | null | undefined;
}): boolean {
return (
isHeadlessExecTrigger(params.trigger) &&
params.unavailableReason === "no-approval-route" &&
params.preResolvedDecision === null
);
}
export function buildHeadlessExecApprovalDeniedMessage(params: {
trigger?: string;
host: "gateway" | "node";
security: ExecSecurity;
ask: ExecAsk;
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
}): string {
const runLabel = params.trigger === "cron" ? "Cron runs" : "Headless runs";
return [
`exec denied: ${runLabel} cannot wait for interactive exec approval.`,
`Effective host exec policy: security=${params.security} ask=${params.ask} askFallback=${params.askFallback}`,
"Stricter values from tools.exec and ~/.openclaw/exec-approvals.json both apply.",
"Fix one of these:",
'- align both files to security="full" and ask="off" for trusted local automation',
"- keep allowlist mode and add an explicit allowlist entry for this command",
"- enable Web UI, terminal UI, or chat exec approvals and rerun interactively",
'Tip: run "openclaw doctor" and "openclaw approvals get --gateway" to inspect the effective policy.',
].join("\n");
}
export async function sendExecApprovalFollowupResult(
target: ExecApprovalFollowupTarget,
resultText: string,

View File

@@ -6,6 +6,7 @@ export type ExecToolDefaults = {
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
trigger?: string;
node?: string;
pathPrepend?: string[];
safeBins?: string[];

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -148,6 +149,7 @@ async function expectGatewayExecWithoutApproval(options: {
config: Record<string, unknown>;
command: string;
ask?: "always" | "on-miss" | "off";
security?: "allowlist" | "full";
}) {
await writeExecApprovalsConfig(options.config);
const calls: string[] = [];
@@ -156,7 +158,7 @@ async function expectGatewayExecWithoutApproval(options: {
const tool = createExecTool({
host: "gateway",
ask: options.ask,
security: "full",
security: options.security,
approvalRunningNoticeMs: 0,
});
@@ -200,6 +202,18 @@ function mockPendingApprovalRegistration() {
});
}
function mockNoApprovalRouteRegistration() {
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { id: "approval-id", decision: null };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
}
describe("exec approvals", () => {
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
@@ -410,6 +424,171 @@ describe("exec approvals", () => {
});
});
it("inherits security=full from exec-approvals defaults when tool security is unset", async () => {
await expectGatewayExecWithoutApproval({
config: {
version: 1,
defaults: { security: "full", ask: "off", askFallback: "full" },
agents: {},
},
command: "echo ok",
security: undefined,
});
});
it("keeps ask=always prompts even when durable allow-always trust matches", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {
main: {
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-gateway-durable-still-prompts", {
command: `${JSON.stringify(process.execPath)} --version`,
});
expect(result.details.status).toBe("approval-pending");
});
it("keeps ask=always prompts for static allowlist entries without allow-always trust", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {
main: {
allowlist: [{ pattern: process.execPath }],
},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-static-allowlist-still-prompts", {
command: `${JSON.stringify(process.execPath)} --version`,
});
expect(result.details.status).toBe("approval-pending");
});
it("keeps ask=always prompts for node-host runs even with durable trust", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
return {
file: {
version: 1,
agents: {
main: {
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
},
},
},
};
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
return { payload: { success: true, stdout: "node-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-node-durable-allow-always", {
command: `${JSON.stringify(process.execPath)} --version`,
});
expect(result.details.status).toBe("approval-pending");
expect(calls).toContain("exec.approval.request");
});
it("reuses exact-command durable trust for node shell-wrapper reruns", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
const prepared = buildPreparedSystemRunPayload({
params: { command: ["/bin/sh", "-lc", "cd ."], cwd: process.cwd() },
}) as { payload?: { plan?: { commandText?: string } } };
const commandText = prepared.payload?.plan?.commandText ?? "";
return {
file: {
version: 1,
agents: {
main: {
allowlist: [
{
pattern: `=command:${crypto
.createHash("sha256")
.update(commandText)
.digest("hex")
.slice(0, 16)}`,
source: "allow-always",
},
],
},
},
},
};
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
return { payload: { success: true, stdout: "node-shell-wrapper-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-node-shell-wrapper-durable-allow-always", {
command: "cd .",
});
expect(result.details.status).toBe("completed");
expect(getResultText(result)).toContain("node-shell-wrapper-ok");
expect(calls).not.toContain("exec.approval.request");
expect(calls).not.toContain("exec.approval.waitDecision");
});
it("requires approval for elevated ask when allowlist misses", async () => {
const calls: string[] = [];
let resolveApproval: (() => void) | undefined;
@@ -925,6 +1104,114 @@ describe("exec approvals", () => {
);
});
it("resolves cron no-route approvals inline when askFallback permits trusted automation", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {},
});
mockNoApprovalRouteRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
trigger: "cron",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-cron-inline-approval", {
command: "echo cron-ok",
});
expect(result.details.status).toBe("completed");
expect(getResultText(result)).toContain("cron-ok");
expect(vi.mocked(callGatewayTool)).toHaveBeenCalledWith(
"exec.approval.request",
expect.anything(),
expect.anything(),
expect.objectContaining({ expectFinal: false }),
);
expect(
vi
.mocked(callGatewayTool)
.mock.calls.some(([method]) => method === "exec.approval.waitDecision"),
).toBe(false);
});
it("forwards inline cron approval state to node system.run", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {},
});
mockNoApprovalRouteRegistration();
let systemRunInvoke: unknown;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
return { id: "approval-id", decision: null };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
systemRunInvoke = params;
return { payload: { success: true, stdout: "cron-node-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "always",
security: "full",
trigger: "cron",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-cron-inline-node-approval", {
command: "echo cron-node-ok",
});
expect(result.details.status).toBe("completed");
expect(getResultText(result)).toContain("cron-node-ok");
expect(systemRunInvoke).toMatchObject({
command: "system.run",
params: {
approved: true,
approvalDecision: "allow-once",
},
});
expect((systemRunInvoke as { params?: { runId?: string } }).params?.runId).toEqual(
expect.any(String),
);
});
it("explains cron no-route denials with a host-policy fix hint", async () => {
mockNoApprovalRouteRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
trigger: "cron",
approvalRunningNoticeMs: 0,
});
await expect(
tool.execute("call-cron-denied", {
command: "echo cron-denied",
}),
).rejects.toThrow("Cron runs cannot wait for interactive exec approval");
});
it("shows a local /approve prompt when discord exec approvals are disabled", async () => {
await writeOpenClawConfig({
channels: {

View File

@@ -594,14 +594,18 @@ export function createExecTool(
});
const host: ExecHost = target.effectiveHost;
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const approvalDefaults = loadExecApprovals().defaults;
const configuredSecurity =
defaults?.security ??
approvalDefaults?.security ??
(host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.ask is unset.
const configuredAsk = defaults?.ask ?? loadExecApprovals().defaults?.ask ?? "on-miss";
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset.
const configuredAsk = defaults?.ask ?? approvalDefaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
@@ -726,6 +730,7 @@ export function createExecTool(
security,
ask,
strictInlineEval: defaults?.strictInlineEval,
trigger: defaults?.trigger,
timeoutSec: params.timeout,
defaultTimeoutSec,
approvalRunningNoticeMs,
@@ -749,6 +754,7 @@ export function createExecTool(
safeBins,
safeBinProfiles,
strictInlineEval: defaults?.strictInlineEval,
trigger: defaults?.trigger,
agentId,
sessionKey: defaults?.sessionKey,
turnSourceChannel: defaults?.messageProvider,
@@ -767,6 +773,9 @@ export function createExecTool(
return gatewayResult.pendingResult;
}
execCommandOverride = gatewayResult.execCommandOverride;
if (gatewayResult.allowWithoutEnforcedCommand) {
execCommandOverride = undefined;
}
}
const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null;

View File

@@ -444,6 +444,7 @@ export function createOpenClawCodingTools(options?: {
host: options?.exec?.host ?? execConfig.host,
security: options?.exec?.security ?? execConfig.security,
ask: options?.exec?.ask ?? execConfig.ask,
trigger: options?.trigger,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@@ -17,12 +20,14 @@ import { noteSecurityWarnings } from "./doctor-security.js";
describe("noteSecurityWarnings gateway exposure", () => {
let prevToken: string | undefined;
let prevPassword: string | undefined;
let prevHome: string | undefined;
beforeEach(() => {
note.mockClear();
pluginRegistry.list = [];
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
prevHome = process.env.HOME;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
});
@@ -38,10 +43,29 @@ describe("noteSecurityWarnings gateway exposure", () => {
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword;
}
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? "");
async function withExecApprovalsFile(
file: Record<string, unknown>,
run: () => Promise<void>,
): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-security-"));
process.env.HOME = home;
await fs.mkdir(path.join(home, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(home, ".openclaw", "exec-approvals.json"),
JSON.stringify(file, null, 2),
);
await run();
}
it("warns when exposed without auth", async () => {
const cfg = { gateway: { bind: "lan" } } as OpenClawConfig;
await noteSecurityWarnings(cfg);
@@ -136,6 +160,148 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).toContain("openclaw approvals get --gateway");
});
it("warns when tools.exec is broader than host exec defaults", async () => {
await withExecApprovalsFile(
{
version: 1,
defaults: {
security: "allowlist",
ask: "on-miss",
},
},
async () => {
await noteSecurityWarnings({
tools: {
exec: {
security: "full",
ask: "off",
},
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).toContain("tools.exec is broader than the host exec policy");
expect(message).toContain('security="full"');
expect(message).toContain('defaults.security="allowlist"');
expect(message).toContain("stricter side wins");
});
it("does not invent a deny host policy when exec-approvals defaults.security is unset", async () => {
await withExecApprovalsFile(
{
version: 1,
agents: {},
},
async () => {
await noteSecurityWarnings({
tools: {
exec: {
security: "allowlist",
ask: "on-miss",
},
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain('security="deny"');
});
it("does not invent an on-miss host ask policy when exec-approvals defaults.ask is unset", async () => {
await withExecApprovalsFile(
{
version: 1,
agents: {},
},
async () => {
await noteSecurityWarnings({
tools: {
exec: {
ask: "always",
},
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain('ask="on-miss"');
});
it("warns when a per-agent exec policy is broader than the matching host agent policy", async () => {
await withExecApprovalsFile(
{
version: 1,
agents: {
runner: {
security: "allowlist",
ask: "always",
},
},
},
async () => {
await noteSecurityWarnings({
agents: {
list: [
{
id: "runner",
tools: {
exec: {
security: "full",
ask: "off",
},
},
},
],
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).toContain("agents.list.runner.tools.exec is broader than the host exec policy");
expect(message).toContain('agents.runner.security="allowlist"');
expect(message).toContain('agents.runner.ask="always"');
});
it('does not warn about durable allow-always trust when ask="always" is enforced', async () => {
await withExecApprovalsFile(
{
version: 1,
defaults: {
ask: "always",
},
agents: {
main: {
allowlist: [
{
pattern: "/usr/bin/echo",
source: "allow-always",
},
],
},
},
},
async () => {
await noteSecurityWarnings({
tools: {
exec: {
ask: "always",
},
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).not.toContain('tools.exec: ask="always" still bypasses future prompts');
});
it("warns when heartbeat delivery relies on implicit directPolicy defaults", async () => {
const cfg = {
agents: {

View File

@@ -6,6 +6,15 @@ import type { AgentConfig } from "../config/types.agents.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import {
loadExecApprovals,
maxAsk,
minSecurity,
resolveExecApprovalsFromFile,
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
import { note } from "../terminal/note.js";
import { resolveDefaultChannelAccountContext } from "./channel-account-context.js";
@@ -48,6 +57,143 @@ function collectImplicitHeartbeatDirectPolicyWarnings(cfg: OpenClawConfig): stri
return warnings;
}
function execSecurityRank(value: ExecSecurity): number {
switch (value) {
case "deny":
return 0;
case "allowlist":
return 1;
case "full":
return 2;
}
}
function execAskRank(value: ExecAsk): number {
switch (value) {
case "off":
return 0;
case "on-miss":
return 1;
case "always":
return 2;
}
}
function resolveHostExecPolicy(params: {
approvals: ExecApprovalsFile;
execConfig: { security?: ExecSecurity; ask?: ExecAsk } | undefined;
agentId?: string;
}): {
security: ExecSecurity;
ask: ExecAsk;
securitySource: string;
askSource: string;
} {
const basePath = "~/.openclaw/exec-approvals.json";
const agentEntry =
params.agentId && params.approvals.agents && params.approvals.agents[params.agentId]
? params.approvals.agents[params.agentId]
: undefined;
const defaults = params.approvals.defaults;
const configuredSecurity = params.execConfig?.security ?? "allowlist";
const configuredAsk = params.execConfig?.ask ?? "on-miss";
const resolved = resolveExecApprovalsFromFile({
file: params.approvals,
agentId: params.agentId,
overrides: {
security: configuredSecurity,
ask: configuredAsk,
},
});
const security = minSecurity(configuredSecurity, resolved.agent.security);
const ask = resolved.agent.ask === "off" ? "off" : maxAsk(configuredAsk, resolved.agent.ask);
return {
security,
ask,
securitySource: agentEntry?.security
? `${basePath} agents.${params.agentId}.security`
: defaults?.security
? `${basePath} defaults.security`
: "caller tool policy fallback",
askSource: agentEntry?.ask
? `${basePath} agents.${params.agentId}.ask`
: defaults?.ask
? `${basePath} defaults.ask`
: "caller tool policy fallback",
};
}
function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] {
const warnings: string[] = [];
const approvals = loadExecApprovals();
const maybeWarn = (params: {
scopeLabel: string;
execConfig: { security?: ExecSecurity; ask?: ExecAsk } | undefined;
agentId?: string;
}) => {
const execConfig = params.execConfig;
if (!execConfig || (!execConfig.security && !execConfig.ask)) {
return;
}
const host = resolveHostExecPolicy({
approvals,
execConfig,
agentId: params.agentId,
});
const securityConflict =
execConfig.security !== undefined &&
execSecurityRank(execConfig.security) > execSecurityRank(host.security);
const askConflict =
execConfig.ask !== undefined && execAskRank(execConfig.ask) < execAskRank(host.ask);
if (!securityConflict && !askConflict) {
return;
}
const configParts: string[] = [];
const hostParts: string[] = [];
if (execConfig.security !== undefined) {
configParts.push(`security="${execConfig.security}"`);
hostParts.push(`${host.securitySource}="${host.security}"`);
}
if (execConfig.ask !== undefined) {
configParts.push(`ask="${execConfig.ask}"`);
hostParts.push(`${host.askSource}="${host.ask}"`);
}
warnings.push(
[
`- ${params.scopeLabel} is broader than the host exec policy.`,
` Config: ${configParts.join(", ")}`,
` Host: ${hostParts.join(", ")}`,
` Effective host exec stays security="${host.security}" ask="${host.ask}" because the stricter side wins.`,
" Headless runs like isolated cron cannot answer approval prompts; align both files or enable Web UI, terminal UI, or chat exec approvals.",
` Inspect with: ${formatCliCommand("openclaw approvals get --gateway")}`,
].join("\n"),
);
};
maybeWarn({
scopeLabel: "tools.exec",
execConfig: cfg.tools?.exec,
});
for (const agent of cfg.agents?.list ?? []) {
maybeWarn({
scopeLabel: `agents.list.${agent.id}.tools.exec`,
execConfig: agent.tools?.exec,
agentId: agent.id,
});
}
return warnings;
}
function collectDurableExecApprovalWarnings(cfg: OpenClawConfig): string[] {
void cfg;
return [];
}
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnings: string[] = [];
const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`;
@@ -61,6 +207,8 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
}
warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg));
warnings.push(...collectExecPolicyConflictWarnings(cfg));
warnings.push(...collectDurableExecApprovalWarnings(cfg));
// ===========================================
// GATEWAY NETWORK EXPOSURE CHECK

View File

@@ -11677,7 +11677,7 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
"execApprovals.approvers": {
label: "Slack Exec Approval Approvers",
help: "Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to owner IDs inferred from channels.slack.allowFrom, channels.slack.dm.allowFrom, and defaultTo when possible.",
help: "Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible.",
},
"execApprovals.agentFilter": {
label: "Slack Exec Approval Agent Filter",

View File

@@ -142,7 +142,7 @@ export type DiscordVoiceConfig = {
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Discord user IDs to receive approval prompts. Optional: falls back to owner IDs inferred from allowFrom/defaultTo when possible. */
/** Discord user IDs to receive approval prompts. Optional: falls back to commands.ownerAllowFrom when possible. */
approvers?: string[];
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];

View File

@@ -54,7 +54,7 @@ export type SlackExecApprovalTarget = "dm" | "channel" | "both";
export type SlackExecApprovalConfig = {
/** Enable Slack exec approvals for this account. Default: false. */
enabled?: boolean;
/** Slack user IDs allowed to approve exec requests. Optional: falls back to owner IDs inferred from allowFrom/defaultTo when possible. */
/** Slack user IDs allowed to approve exec requests. Optional: falls back to commands.ownerAllowFrom when possible. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];

View File

@@ -109,6 +109,7 @@ function isPathScopedExecutableToken(token: string): boolean {
export type ExecAllowlistEvaluation = {
allowlistSatisfied: boolean;
allowlistMatches: ExecAllowlistEntry[];
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
};
@@ -368,15 +369,18 @@ function evaluateSegments(
): {
satisfied: boolean;
matches: ExecAllowlistEntry[];
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
} {
const matches: ExecAllowlistEntry[] = [];
const skillBinTrust = buildSkillBinTrustIndex(params.skillBins);
const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0;
const segmentAllowlistEntries: Array<ExecAllowlistEntry | null> = [];
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
const satisfied = segments.every((segment) => {
if (segment.resolution?.policyBlocked === true) {
segmentAllowlistEntries.push(null);
segmentSatisfiedBy.push(null);
return false;
}
@@ -412,6 +416,7 @@ function evaluateSegments(
if (match) {
matches.push(match);
}
segmentAllowlistEntries.push(match ?? null);
const safe = isSafeBinUsage({
argv: effectiveArgv,
resolution: resolveExecutionTargetResolution(segment.resolution),
@@ -436,7 +441,7 @@ function evaluateSegments(
return Boolean(by);
});
return { satisfied, matches, segmentSatisfiedBy };
return { satisfied, matches, segmentAllowlistEntries, segmentSatisfiedBy };
}
function resolveAnalysisSegmentGroups(analysis: ExecCommandAnalysis): ExecCommandSegment[][] {
@@ -452,9 +457,15 @@ export function evaluateExecAllowlist(
} & ExecAllowlistContext,
): ExecAllowlistEvaluation {
const allowlistMatches: ExecAllowlistEntry[] = [];
const segmentAllowlistEntries: Array<ExecAllowlistEntry | null> = [];
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
if (!params.analysis.ok || params.analysis.segments.length === 0) {
return { allowlistSatisfied: false, allowlistMatches, segmentSatisfiedBy };
return {
allowlistSatisfied: false,
allowlistMatches,
segmentAllowlistEntries,
segmentSatisfiedBy,
};
}
const allowlistContext = pickExecAllowlistContext(params);
@@ -466,15 +477,27 @@ export function evaluateExecAllowlist(
return {
allowlistSatisfied: false,
allowlistMatches: result.matches,
segmentAllowlistEntries: result.segmentAllowlistEntries,
segmentSatisfiedBy: result.segmentSatisfiedBy,
};
}
return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] };
return {
allowlistSatisfied: false,
allowlistMatches: [],
segmentAllowlistEntries: [],
segmentSatisfiedBy: [],
};
}
allowlistMatches.push(...result.matches);
segmentAllowlistEntries.push(...result.segmentAllowlistEntries);
segmentSatisfiedBy.push(...result.segmentSatisfiedBy);
}
return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy };
return {
allowlistSatisfied: true,
allowlistMatches,
segmentAllowlistEntries,
segmentSatisfiedBy,
};
}
export type ExecAllowlistAnalysis = {
@@ -482,6 +505,7 @@ export type ExecAllowlistAnalysis = {
allowlistSatisfied: boolean;
allowlistMatches: ExecAllowlistEntry[];
segments: ExecCommandSegment[];
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
};
@@ -700,6 +724,7 @@ export function evaluateShellAllowlist(
allowlistSatisfied: false,
allowlistMatches: [],
segments: [],
segmentAllowlistEntries: [],
segmentSatisfiedBy: [],
});
@@ -728,6 +753,7 @@ export function evaluateShellAllowlist(
allowlistSatisfied: evaluation.allowlistSatisfied,
allowlistMatches: evaluation.allowlistMatches,
segments: analysis.segments,
segmentAllowlistEntries: evaluation.segmentAllowlistEntries,
segmentSatisfiedBy: evaluation.segmentSatisfiedBy,
};
}
@@ -793,15 +819,20 @@ export function evaluateShellAllowlist(
}
const allowlistMatches: ExecAllowlistEntry[] = [];
const segments: ExecCommandSegment[] = [];
const segmentAllowlistEntries: Array<ExecAllowlistEntry | null> = [];
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
for (const [index, { analysis, evaluation }] of finalizedEvaluations.entries()) {
const effectiveSegmentSatisfiedBy = allowSkillPreludeAtIndex.has(index)
? analysis.segments.map(() => "skillPrelude" as const)
: evaluation.segmentSatisfiedBy;
const effectiveSegmentAllowlistEntries = allowSkillPreludeAtIndex.has(index)
? analysis.segments.map(() => null)
: evaluation.segmentAllowlistEntries;
segments.push(...analysis.segments);
allowlistMatches.push(...evaluation.allowlistMatches);
segmentAllowlistEntries.push(...effectiveSegmentAllowlistEntries);
segmentSatisfiedBy.push(...effectiveSegmentSatisfiedBy);
if (!evaluation.allowlistSatisfied && !allowSkillPreludeAtIndex.has(index)) {
return {
@@ -809,6 +840,7 @@ export function evaluateShellAllowlist(
allowlistSatisfied: false,
allowlistMatches,
segments,
segmentAllowlistEntries,
segmentSatisfiedBy,
};
}
@@ -819,6 +851,7 @@ export function evaluateShellAllowlist(
allowlistSatisfied: true,
allowlistMatches,
segments,
segmentAllowlistEntries,
segmentSatisfiedBy,
};
}

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
} from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
hasDurableExecApproval,
maxAsk,
minSecurity,
normalizeExecAsk,
@@ -77,6 +83,14 @@ describe("exec approvals policy helpers", () => {
allowlistSatisfied: true,
expected: true,
},
{
ask: "always" as const,
security: "full" as const,
analysisOk: true,
allowlistSatisfied: false,
durableApprovalSatisfied: true,
expected: true,
},
{
ask: "off" as const,
security: "allowlist" as const,
@@ -108,4 +122,71 @@ describe("exec approvals policy helpers", () => {
])("requiresExecApproval respects ask mode and allowlist satisfaction for %j", (testCase) => {
expect(requiresExecApproval(testCase)).toBe(testCase.expected);
});
it("treats exact-command allow-always approvals as durable trust", () => {
expect(
hasDurableExecApproval({
analysisOk: false,
segmentAllowlistEntries: [],
allowlist: [
{
pattern: "=command:613b5a60181648fd",
source: "allow-always",
},
],
commandText: 'powershell -NoProfile -Command "Write-Output hi"',
}),
).toBe(true);
});
it("marks policy-blocked segments as non-durable allowlist entries", () => {
const executable = makeMockExecutableResolution({
rawExecutable: "/usr/bin/echo",
resolvedPath: "/usr/bin/echo",
executableName: "echo",
});
const result = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: "/usr/bin/echo ok",
argv: ["/usr/bin/echo", "ok"],
resolution: makeMockCommandResolution({
execution: executable,
}),
},
{
raw: "/bin/sh -lc whoami",
argv: ["/bin/sh", "-lc", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/sh",
resolvedPath: "/bin/sh",
executableName: "sh",
}),
policyBlocked: true,
}),
},
],
},
allowlist: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
safeBins: new Set(),
cwd: "/tmp",
platform: process.platform,
});
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentAllowlistEntries).toEqual([
expect.objectContaining({ pattern: "/usr/bin/echo" }),
null,
]);
expect(
hasDurableExecApproval({
analysisOk: true,
segmentAllowlistEntries: result.segmentAllowlistEntries,
allowlist: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
}),
).toBe(false);
});
});

View File

@@ -14,6 +14,7 @@ import type { ExecApprovalsFile } from "./exec-approvals.js";
type ExecApprovalsModule = typeof import("./exec-approvals.js");
let addAllowlistEntry: ExecApprovalsModule["addAllowlistEntry"];
let addDurableCommandApproval: ExecApprovalsModule["addDurableCommandApproval"];
let ensureExecApprovals: ExecApprovalsModule["ensureExecApprovals"];
let mergeExecApprovalsSocketDefaults: ExecApprovalsModule["mergeExecApprovalsSocketDefaults"];
let normalizeExecApprovals: ExecApprovalsModule["normalizeExecApprovals"];
@@ -29,6 +30,7 @@ const originalOpenClawHome = process.env.OPENCLAW_HOME;
beforeAll(async () => {
({
addAllowlistEntry,
addDurableCommandApproval,
ensureExecApprovals,
mergeExecApprovalsSocketDefaults,
normalizeExecApprovals,
@@ -168,6 +170,65 @@ describe("exec approvals store helpers", () => {
expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i);
});
it("persists durable command approvals without storing plaintext command text", () => {
const dir = createHomeDir();
vi.spyOn(Date, "now").mockReturnValue(321_000);
const approvals = ensureExecApprovals();
addDurableCommandApproval(approvals, "worker", 'printenv API_KEY="secret-value"');
expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([
expect.objectContaining({
source: "allow-always",
lastUsedAt: 321_000,
}),
]);
expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]?.pattern).toMatch(
/^=command:[0-9a-f]{16}$/i,
);
expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]).not.toHaveProperty("commandText");
});
it("strips legacy plaintext command text during normalization", () => {
expect(
normalizeExecApprovals({
version: 1,
agents: {
main: {
allowlist: [
{
pattern: "=command:test",
source: "allow-always",
commandText: "echo secret-token",
},
],
},
},
}).agents?.main?.allowlist,
).toEqual([
expect.objectContaining({
pattern: "=command:test",
source: "allow-always",
}),
]);
expect(
normalizeExecApprovals({
version: 1,
agents: {
main: {
allowlist: [
{
pattern: "=command:test",
source: "allow-always",
commandText: "echo secret-token",
},
],
},
},
}).agents?.main?.allowlist?.[0],
).not.toHaveProperty("commandText");
});
it("records allowlist usage on the matching entry and backfills missing ids", () => {
const dir = createHomeDir();
vi.spyOn(Date, "now").mockReturnValue(999_000);

View File

@@ -115,6 +115,8 @@ export type ExecApprovalsDefaults = {
export type ExecAllowlistEntry = {
id?: string;
pattern: string;
source?: "allow-always";
commandText?: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
@@ -265,6 +267,24 @@ function ensureAllowlistIds(
return changed ? next : allowlist;
}
function stripAllowlistCommandText(
allowlist: ExecAllowlistEntry[] | undefined,
): ExecAllowlistEntry[] | undefined {
if (!Array.isArray(allowlist) || allowlist.length === 0) {
return allowlist;
}
let changed = false;
const next = allowlist.map((entry) => {
if (typeof entry.commandText !== "string") {
return entry;
}
changed = true;
const { commandText: _commandText, ...rest } = entry;
return rest;
});
return changed ? next : allowlist;
}
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
@@ -277,7 +297,8 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
}
for (const [key, agent] of Object.entries(agents)) {
const coerced = coerceAllowlistEntries(agent.allowlist);
const allowlist = ensureAllowlistIds(coerced);
const withIds = ensureAllowlistIds(coerced);
const allowlist = stripAllowlistCommandText(withIds);
if (allowlist !== agent.allowlist) {
agents[key] = { ...agent, allowlist };
}
@@ -495,15 +516,52 @@ export function requiresExecApproval(params: {
security: ExecSecurity;
analysisOk: boolean;
allowlistSatisfied: boolean;
durableApprovalSatisfied?: boolean;
}): boolean {
if (params.ask === "always") {
return true;
}
if (params.durableApprovalSatisfied === true) {
return false;
}
return (
params.ask === "always" ||
(params.ask === "on-miss" &&
params.security === "allowlist" &&
(!params.analysisOk || !params.allowlistSatisfied))
params.ask === "on-miss" &&
params.security === "allowlist" &&
(!params.analysisOk || !params.allowlistSatisfied)
);
}
export function hasDurableExecApproval(params: {
analysisOk: boolean;
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
allowlist?: readonly ExecAllowlistEntry[];
commandText?: string | null;
}): boolean {
const normalizedCommand = params.commandText?.trim();
const commandPattern = normalizedCommand
? buildDurableCommandApprovalPattern(normalizedCommand)
: null;
const exactCommandMatch = normalizedCommand
? (params.allowlist ?? []).some(
(entry) =>
entry.source === "allow-always" &&
(entry.pattern === commandPattern ||
(typeof entry.commandText === "string" &&
entry.commandText.trim() === normalizedCommand)),
)
: false;
const allowlistMatch =
params.analysisOk &&
params.segmentAllowlistEntries.length > 0 &&
params.segmentAllowlistEntries.every((entry) => entry?.source === "allow-always");
return exactCommandMatch || allowlistMatch;
}
function buildDurableCommandApprovalPattern(commandText: string): string {
const digest = crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16);
return `=command:${digest}`;
}
export function recordAllowlistUse(
approvals: ExecApprovalsFile,
agentId: string | undefined,
@@ -535,6 +593,9 @@ export function addAllowlistEntry(
approvals: ExecApprovalsFile,
agentId: string | undefined,
pattern: string,
options?: {
source?: ExecAllowlistEntry["source"];
},
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
@@ -544,15 +605,49 @@ export function addAllowlistEntry(
if (!trimmed) {
return;
}
if (allowlist.some((entry) => entry.pattern === trimmed)) {
const existingEntry = allowlist.find((entry) => entry.pattern === trimmed);
if (existingEntry && (!options?.source || existingEntry.source === options.source)) {
return;
}
allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
agents[target] = { ...existing, allowlist };
const now = Date.now();
const nextAllowlist = existingEntry
? allowlist.map((entry) =>
entry.pattern === trimmed
? {
...entry,
source: options?.source ?? entry.source,
lastUsedAt: now,
}
: entry,
)
: [
...allowlist,
{
id: crypto.randomUUID(),
pattern: trimmed,
source: options?.source,
lastUsedAt: now,
},
];
agents[target] = { ...existing, allowlist: nextAllowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
}
export function addDurableCommandApproval(
approvals: ExecApprovalsFile,
agentId: string | undefined,
commandText: string,
) {
const normalized = commandText.trim();
if (!normalized) {
return;
}
addAllowlistEntry(approvals, agentId, buildDurableCommandApprovalPattern(normalized), {
source: "allow-always",
});
}
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
return order[a] <= order[b] ? a : b;

View File

@@ -91,6 +91,20 @@ describe("evaluateSystemRunPolicy", () => {
expect(denied.requiresAsk).toBe(true);
});
it("still requires approval when ask=always even with durable trust", () => {
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(
buildPolicyParams({
security: "full",
ask: "always",
durableApprovalSatisfied: true,
}),
),
);
expect(denied.eventReason).toBe("approval-required");
expect(denied.requiresAsk).toBe(true);
});
it("allows allowlist miss when explicit approval is provided", () => {
const allowed = expectAllowedDecision(
evaluateSystemRunPolicy(

View File

@@ -54,6 +54,7 @@ export function evaluateSystemRunPolicy(params: {
ask: ExecAsk;
analysisOk: boolean;
allowlistSatisfied: boolean;
durableApprovalSatisfied?: boolean;
approvalDecision: ExecApprovalDecision;
approved?: boolean;
isWindows: boolean;
@@ -87,6 +88,7 @@ export function evaluateSystemRunPolicy(params: {
security: params.security,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied: params.durableApprovalSatisfied,
});
if (requiresAsk && !approvedByAsk) {
return {
@@ -104,6 +106,18 @@ export function evaluateSystemRunPolicy(params: {
}
if (params.security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
if (params.durableApprovalSatisfied) {
return {
allowed: true,
analysisOk,
allowlistSatisfied,
shellWrapperBlocked,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
return {
allowed: false,
eventReason: "allowlist-miss",

View File

@@ -17,6 +17,7 @@ export type SystemRunAllowlistAnalysis = {
allowlistMatches: ExecAllowlistEntry[];
allowlistSatisfied: boolean;
segments: ExecCommandSegment[];
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
};
export function evaluateSystemRunAllowlist(params: {
@@ -53,6 +54,7 @@ export function evaluateSystemRunAllowlist(params: {
? allowlistEval.allowlistSatisfied
: false,
segments: allowlistEval.segments,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
};
}
@@ -73,6 +75,7 @@ export function evaluateSystemRunAllowlist(params: {
allowlistSatisfied:
params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false,
segments: analysis.segments,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
};
}

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -1480,4 +1481,60 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
clearRuntimeConfigSnapshot();
}
});
it("reuses exact-command durable trust for shell-wrapper reruns", async () => {
if (process.platform === "win32") {
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-wrapper-allow-"));
try {
const prepared = buildSystemRunApprovalPlan({
command: ["/bin/sh", "-lc", "cd ."],
cwd: tempDir,
});
expect(prepared.ok).toBe(true);
if (!prepared.ok) {
throw new Error("unreachable");
}
await withTempApprovalsHome({
approvals: {
version: 1,
defaults: { security: "allowlist", ask: "on-miss", askFallback: "full" },
agents: {
main: {
allowlist: [
{
pattern: `=command:${crypto
.createHash("sha256")
.update(prepared.plan.commandText)
.digest("hex")
.slice(0, 16)}`,
source: "allow-always",
},
],
},
},
},
run: async () => {
const rerun = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.commandText,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? tempDir,
security: "allowlist",
ask: "on-miss",
runCommand: vi.fn(async () => createLocalRunResult("shell-wrapper-reused")),
});
expect(rerun.runCommand).toHaveBeenCalledTimes(1);
expectInvokeOk(rerun.sendInvokeResult, { payloadContains: "shell-wrapper-reused" });
},
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -3,7 +3,9 @@ import { resolveAgentConfig } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import type { GatewayClient } from "../gateway/client.js";
import {
addDurableCommandApproval,
addAllowlistEntry,
hasDurableExecApproval,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
resolveAllowAlwaysPatterns,
@@ -96,6 +98,7 @@ type SystemRunPolicyPhase = SystemRunParsePhase & {
approvals: ResolvedExecApprovals;
security: ExecSecurity;
policy: ReturnType<typeof evaluateSystemRunPolicy>;
durableApprovalSatisfied: boolean;
strictInlineEval: boolean;
inlineEvalHit: ReturnType<typeof detectInterpreterInlineEvalArgv>;
allowlistMatches: ExecAllowlistEntry[];
@@ -332,19 +335,20 @@ async function evaluateSystemRunPolicyPhase(
onWarning: warnWritableTrustedDirOnce,
});
const bins = autoAllowSkills ? await opts.skillBins.current() : [];
let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({
shellCommand: parsed.shellPayload,
argv: parsed.argv,
approvals,
security,
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
cwd: parsed.cwd,
env: parsed.env,
skillBins: bins,
autoAllowSkills,
});
let { analysisOk, allowlistMatches, allowlistSatisfied, segments, segmentAllowlistEntries } =
evaluateSystemRunAllowlist({
shellCommand: parsed.shellPayload,
argv: parsed.argv,
approvals,
security,
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
cwd: parsed.cwd,
env: parsed.env,
skillBins: bins,
autoAllowSkills,
});
const strictInlineEval =
agentExec?.strictInlineEval === true || cfg.tools?.exec?.strictInlineEval === true;
const inlineEvalHit = strictInlineEval
@@ -358,11 +362,18 @@ async function evaluateSystemRunPolicyPhase(
const cmdInvocation = parsed.shellPayload
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
: opts.isCmdExeInvocation(parsed.argv);
const durableApprovalSatisfied = hasDurableExecApproval({
analysisOk,
segmentAllowlistEntries,
allowlist: approvals.allowlist,
commandText: parsed.commandText,
});
const policy = evaluateSystemRunPolicy({
security,
ask,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
approvalDecision: parsed.approvalDecision,
approved: parsed.approved,
isWindows,
@@ -390,7 +401,12 @@ async function evaluateSystemRunPolicyPhase(
}
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
if (security === "allowlist" && parsed.shellPayload && !policy.approvedByAsk) {
if (
security === "allowlist" &&
parsed.shellPayload &&
!policy.approvedByAsk &&
!durableApprovalSatisfied
) {
await sendSystemRunDenied(opts, parsed.execution, {
reason: "approval-required",
message: "SYSTEM_RUN_DENIED: approval required",
@@ -440,6 +456,7 @@ async function evaluateSystemRunPolicyPhase(
approvals,
security,
policy,
durableApprovalSatisfied,
strictInlineEval,
inlineEvalHit,
allowlistMatches,
@@ -546,25 +563,24 @@ async function executeSystemRunPhase(
}
}
if (
phase.policy.approvalDecision === "allow-always" &&
phase.security === "allowlist" &&
phase.inlineEvalHit === null
) {
if (phase.policy.analysisOk) {
const patterns = resolveAllowAlwaysPatterns({
segments: phase.segments,
cwd: phase.cwd,
env: phase.env,
platform: process.platform,
strictInlineEval: phase.strictInlineEval,
});
for (const pattern of patterns) {
if (pattern) {
addAllowlistEntry(phase.approvals.file, phase.agentId, pattern);
}
if (phase.policy.approvalDecision === "allow-always" && phase.inlineEvalHit === null) {
const patterns = resolveAllowAlwaysPatterns({
segments: phase.segments,
cwd: phase.cwd,
env: phase.env,
platform: process.platform,
strictInlineEval: phase.strictInlineEval,
});
for (const pattern of patterns) {
if (pattern) {
addAllowlistEntry(phase.approvals.file, phase.agentId, pattern, {
source: "allow-always",
});
}
}
if (patterns.length === 0) {
addDurableCommandApproval(phase.approvals.file, phase.agentId, phase.commandText);
}
}
if (phase.allowlistMatches.length > 0) {

View File

@@ -90,6 +90,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
action: "approve",
}),
).toEqual({ kind: "disabled" });
expect(
getActionAvailabilityState({
cfg: {} as never,
accountId: "disabled",
action: "approve",
}),
).toEqual({ kind: "disabled" });
expect(hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
expect(nativeCapabilities).toEqual({
enabled: true,

View File

@@ -88,7 +88,8 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
accountId?: string | null;
action: "approve";
}) =>
params.hasApprovers({ cfg, accountId })
params.hasApprovers({ cfg, accountId }) &&
params.isNativeDeliveryEnabled({ cfg, accountId })
? ({ kind: "enabled" } as const)
: ({ kind: "disabled" } as const),
},