mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 07:41:08 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user