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,