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

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