mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 17:43:05 +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:
124
src/agents/bash-tools.exec-host-gateway.test.ts
Normal file
124
src/agents/bash-tools.exec-host-gateway.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ExecToolDefaults = {
|
||||
host?: ExecTarget;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
trigger?: string;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user