mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 09:54:03 +00:00
* fix(exec): resume agent turn for native chat exec approvals (issue #93918) Extend the inline approval-pending path that PR #85239 added for webchat to every bundled chat channel that ships an `approval-handler.runtime` adapter (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, Matrix, Google Chat, QQ Bot, plus webchat). When the originating turn can be approved in the same chat, the gateway resolves the approval in place and the agent waits inline for the command output instead of terminating the run on the "approval-pending" tool result. Before this fix, native chat approvals landed in the fire-and-forget `sendExecApprovalFollowup` path. The followup either failed silently against the agent dispatch and fell through to a direct delivery to the operator, or never reached the agent at all; either way the model never saw an "Exec running / Exec finished / Exec denied" event. The operator had to send a follow-up message to recover the turn, and a new approval was minted because the original run had already ended. The change: - Introduces `NATIVE_APPROVAL_CHANNELS` and `isNativeApprovalChannel` in `src/utils/message-channel-constants.ts`, listing the channels that ship a native chat approval client. `webchat` is included so the single-channel check inside `shouldAwaitGatewayApprovalInline` can move from "this one id" to "any native approval client". - Replaces the `INTERNAL_MESSAGE_CHANNEL` equality check in `shouldAwaitGatewayApprovalInline` with `isNativeApprovalChannel`, preserving the `approvalFollowupMode` opt-out and the existing `unavailableReason === null` gate. - Adds unit tests asserting inline resolution and inline denial for every native approval channel, plus a regression test that non-native channels (e.g. feishu) and explicit `approvalFollowupMode` settings still take the fire-and-forget path. - Adds a `NATIVE_APPROVAL_CHANNELS` test in `src/utils/message-channel.test.ts` to lock the membership and the negative cases. Refs https://github.com/openclaw/openclaw/issues/93918 * fix(lint): restore InternalMessageChannel type export lost during rebase Rebase on upstream/main dropped the InternalMessageChannel type alias from message-channel-constants.ts, breaking the plugin-sdk boundary .dts check ('has no exported member named InternalMessageChannel'). message-channel.ts was also re-importing the type only to re-export it, triggering the oxlint no-unused-vars rule. - Re-add 'export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL' in message-channel-constants.ts so the public re-export is valid. - Drop the redundant 'type InternalMessageChannel' from the local import in message-channel.ts; the value-side import is what the file body actually needs. * test(exec): align native approval routing expectations
1700 lines
58 KiB
TypeScript
1700 lines
58 KiB
TypeScript
/**
|
|
* Gateway-host exec approval tests.
|
|
* Covers allowlist misses, auto-review, strict inline eval, diagnostics
|
|
* follow-ups, and gateway approval result routing.
|
|
*/
|
|
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
import {
|
|
onInternalDiagnosticEvent,
|
|
resetDiagnosticEventsForTest,
|
|
type DiagnosticSecurityEvent,
|
|
} from "../infra/diagnostic-events.js";
|
|
import type {
|
|
ExecApprovalDecision,
|
|
ExecCommandSegment,
|
|
ExecSegmentSatisfiedBy,
|
|
} from "../infra/exec-approvals.js";
|
|
import {
|
|
planShellAuthorization,
|
|
type ExecAuthorizationPlan,
|
|
} from "../infra/exec-authorization-plan.js";
|
|
import type { ExecApprovalFollowupTarget } from "./bash-tools.exec-host-shared.js";
|
|
import type { ExecApprovalFollowupFactory } from "./bash-tools.exec-types.js";
|
|
|
|
type StrictInlineEvalBoundary =
|
|
typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary;
|
|
type SendExecApprovalFollowupResult =
|
|
typeof import("./bash-tools.exec-host-shared.js").sendExecApprovalFollowupResult;
|
|
type ExecAutoReviewer = typeof import("../infra/exec-auto-review.js").defaultExecAutoReviewer;
|
|
type BuildExecApprovalFollowupTargetMock = (
|
|
value: ExecApprovalFollowupTarget,
|
|
) => ExecApprovalFollowupTarget | null;
|
|
type MockAllowlistSegment = Omit<ExecCommandSegment, "raw"> & { raw?: string };
|
|
type MockAllowlistResult = {
|
|
allowlistMatches: unknown[];
|
|
analysisOk: boolean;
|
|
allowlistSatisfied: boolean;
|
|
segments: MockAllowlistSegment[];
|
|
segmentAllowlistEntries: unknown[];
|
|
segmentSatisfiedBy?: ExecSegmentSatisfiedBy[];
|
|
authorizationPlan?: ExecAuthorizationPlan;
|
|
};
|
|
|
|
const INLINE_EVAL_HIT = {
|
|
executable: "python3",
|
|
normalizedExecutable: "python3",
|
|
flag: "-c",
|
|
argv: ["python3", "-c", "print(1)"],
|
|
};
|
|
|
|
const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn());
|
|
const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn());
|
|
const buildExecApprovalFollowupTargetMock = vi.hoisted(() =>
|
|
vi.fn<BuildExecApprovalFollowupTargetMock>(() => null),
|
|
);
|
|
const createExecApprovalDecisionStateMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
(): {
|
|
baseDecision: { timedOut: boolean };
|
|
approvedByAsk: boolean;
|
|
deniedReason: string | null;
|
|
} => ({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "approval-required",
|
|
}),
|
|
),
|
|
);
|
|
const evaluateShellAllowlistWithAuthorizationMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
(): MockAllowlistResult => ({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
|
|
segmentSatisfiedBy: [],
|
|
}),
|
|
),
|
|
);
|
|
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
|
|
const hasExactCommandDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => false));
|
|
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => false));
|
|
const resolveExecApprovalAllowedDecisionsMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
(params?: {
|
|
ask?: string | null;
|
|
allowAlwaysPersistence?: { kind: string } | null;
|
|
}): readonly ExecApprovalDecision[] =>
|
|
params?.ask === "always" || params?.allowAlwaysPersistence?.kind === "one-shot"
|
|
? ["allow-once", "deny"]
|
|
: ["allow-once", "allow-always", "deny"],
|
|
),
|
|
);
|
|
const resolveExecApprovalUnavailableDecisionsMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
(params?: {
|
|
ask?: string | null;
|
|
allowAlwaysPersistence?: { kind: string } | null;
|
|
}): readonly ["allow-always"] | readonly [] =>
|
|
params?.ask === "always" || params?.allowAlwaysPersistence?.kind === "one-shot"
|
|
? ["allow-always"]
|
|
: [],
|
|
),
|
|
);
|
|
const buildEnforcedShellCommandMock = vi.hoisted(() =>
|
|
vi.fn((): { ok: boolean; reason?: string; command?: string } => ({
|
|
ok: false,
|
|
reason: "segment execution plan unavailable",
|
|
})),
|
|
);
|
|
const defaultExecAutoReviewerMock = vi.hoisted(() =>
|
|
vi.fn<ExecAutoReviewer>(async () => ({
|
|
decision: "allow-once",
|
|
risk: "low",
|
|
rationale: "allowed",
|
|
})),
|
|
);
|
|
const recordAllowlistMatchesUseMock = vi.hoisted(() => vi.fn());
|
|
const resolveApprovalDecisionOrUndefinedMock = vi.hoisted(() =>
|
|
vi.fn(async (): Promise<string | null | undefined> => undefined),
|
|
);
|
|
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
})),
|
|
);
|
|
const runExecProcessMock = vi.hoisted(() => vi.fn());
|
|
const sendExecApprovalFollowupResultMock = vi.hoisted(() =>
|
|
vi.fn<SendExecApprovalFollowupResult>(async () => undefined),
|
|
);
|
|
const shouldResolveExecApprovalUnavailableInlineMock = vi.hoisted(() => vi.fn(() => false));
|
|
const enforceStrictInlineEvalApprovalBoundaryMock = vi.hoisted(() =>
|
|
vi.fn<StrictInlineEvalBoundary>((value) => ({
|
|
approvedByAsk: value.approvedByAsk,
|
|
deniedReason: value.deniedReason,
|
|
})),
|
|
);
|
|
const detectInterpreterInlineEvalArgvMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
(): {
|
|
executable: string;
|
|
normalizedExecutable: string;
|
|
flag: string;
|
|
argv: string[];
|
|
} | null => null,
|
|
),
|
|
);
|
|
|
|
vi.mock("../infra/exec-approvals.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../infra/exec-approvals.js")>()),
|
|
evaluateShellAllowlistWithAuthorization: evaluateShellAllowlistWithAuthorizationMock,
|
|
hasDurableExecApproval: hasDurableExecApprovalMock,
|
|
hasExactCommandDurableExecApproval: hasExactCommandDurableExecApprovalMock,
|
|
buildEnforcedShellCommand: buildEnforcedShellCommandMock,
|
|
requiresExecApproval: requiresExecApprovalMock,
|
|
recordAllowlistUse: vi.fn(),
|
|
recordAllowlistMatchesUse: recordAllowlistMatchesUseMock,
|
|
resolveApprovalAuditTrustPath: vi.fn(() => null),
|
|
resolveAllowAlwaysPatterns: vi.fn(() => []),
|
|
resolveExecApprovalAllowedDecisions: resolveExecApprovalAllowedDecisionsMock,
|
|
resolveExecApprovalUnavailableDecisions: resolveExecApprovalUnavailableDecisionsMock,
|
|
addAllowlistEntry: vi.fn(),
|
|
addDurableCommandApproval: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../infra/exec-auto-review.js", () => ({
|
|
defaultExecAutoReviewer: defaultExecAutoReviewerMock,
|
|
}));
|
|
|
|
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: resolveExecHostApprovalContextMock,
|
|
buildDefaultExecApprovalRequestArgs: vi.fn(() => ({})),
|
|
buildHeadlessExecApprovalDeniedMessage: vi.fn(() => "denied"),
|
|
buildExecApprovalFollowupTarget: buildExecApprovalFollowupTargetMock,
|
|
buildExecApprovalPendingToolResult: buildExecApprovalPendingToolResultMock,
|
|
createExecApprovalDecisionState: createExecApprovalDecisionStateMock,
|
|
createAndRegisterDefaultExecApprovalRequest: createAndRegisterDefaultExecApprovalRequestMock,
|
|
enforceStrictInlineEvalApprovalBoundary: enforceStrictInlineEvalApprovalBoundaryMock,
|
|
resolveApprovalDecisionOrUndefined: resolveApprovalDecisionOrUndefinedMock,
|
|
sendExecApprovalFollowupResult: sendExecApprovalFollowupResultMock,
|
|
shouldResolveExecApprovalUnavailableInline: shouldResolveExecApprovalUnavailableInlineMock,
|
|
}));
|
|
|
|
vi.mock("./bash-tools.exec-runtime.js", () => ({
|
|
DEFAULT_NOTIFY_TAIL_CHARS: 1000,
|
|
createApprovalSlug: vi.fn(() => "slug"),
|
|
normalizeNotifyOutput: vi.fn((value) => value),
|
|
runExecProcess: runExecProcessMock,
|
|
}));
|
|
|
|
vi.mock("./bash-process-registry.js", () => ({
|
|
markBackgrounded: vi.fn(),
|
|
tail: vi.fn((value) => value),
|
|
}));
|
|
|
|
vi.mock("../infra/command-analysis/inline-eval.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../infra/command-analysis/inline-eval.js")>()),
|
|
describeInterpreterInlineEval: vi.fn(() => "python -c"),
|
|
detectInterpreterInlineEvalArgv: detectInterpreterInlineEvalArgvMock,
|
|
}));
|
|
|
|
let processGatewayAllowlist: typeof import("./bash-tools.exec-host-gateway.js").processGatewayAllowlist;
|
|
type GatewayAllowlistParams = Parameters<typeof processGatewayAllowlist>[0];
|
|
|
|
function requireBuildFollowupTargetInput(callIndex: number): ExecApprovalFollowupTarget {
|
|
const call = buildExecApprovalFollowupTargetMock.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`expected build followup target call ${callIndex}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
function requireSentFollowupTarget(
|
|
callIndex: number,
|
|
): Parameters<SendExecApprovalFollowupResult>[0] {
|
|
const call = sendExecApprovalFollowupResultMock.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`expected sent followup call ${callIndex}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
function requireSentFollowupText(callIndex: number): string {
|
|
const call = sendExecApprovalFollowupResultMock.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`expected sent followup call ${callIndex}`);
|
|
}
|
|
return call[1] ?? "";
|
|
}
|
|
|
|
function requireApprovalFollowupInput(
|
|
mock: Mock<ExecApprovalFollowupFactory>,
|
|
callIndex: number,
|
|
): Parameters<ExecApprovalFollowupFactory>[0] {
|
|
const call = mock.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`expected approval followup call ${callIndex}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
function captureSecurityEvents(): {
|
|
events: DiagnosticSecurityEvent[];
|
|
stop: () => void;
|
|
} {
|
|
const events: DiagnosticSecurityEvent[] = [];
|
|
const stop = onInternalDiagnosticEvent((event, metadata) => {
|
|
if (metadata.trusted && event.type === "security.event") {
|
|
events.push(event);
|
|
}
|
|
});
|
|
return { events, stop };
|
|
}
|
|
|
|
describe("processGatewayAllowlist", () => {
|
|
beforeAll(async () => {
|
|
({ processGatewayAllowlist } = await import("./bash-tools.exec-host-gateway.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetDiagnosticEventsForTest();
|
|
buildExecApprovalPendingToolResultMock.mockReset();
|
|
buildExecApprovalFollowupTargetMock.mockReset();
|
|
buildExecApprovalFollowupTargetMock.mockReturnValue(null);
|
|
createExecApprovalDecisionStateMock.mockReset();
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "approval-required",
|
|
});
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReset();
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
|
|
segmentSatisfiedBy: [],
|
|
});
|
|
hasDurableExecApprovalMock.mockReset();
|
|
hasDurableExecApprovalMock.mockReturnValue(true);
|
|
hasExactCommandDurableExecApprovalMock.mockReset();
|
|
hasExactCommandDurableExecApprovalMock.mockReturnValue(false);
|
|
requiresExecApprovalMock.mockReset();
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
resolveExecApprovalAllowedDecisionsMock.mockClear();
|
|
buildEnforcedShellCommandMock.mockReset();
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: false,
|
|
reason: "segment execution plan unavailable",
|
|
});
|
|
defaultExecAutoReviewerMock.mockReset();
|
|
defaultExecAutoReviewerMock.mockResolvedValue({
|
|
decision: "allow-once",
|
|
risk: "low",
|
|
rationale: "allowed",
|
|
});
|
|
recordAllowlistMatchesUseMock.mockReset();
|
|
resolveApprovalDecisionOrUndefinedMock.mockReset();
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(undefined);
|
|
shouldResolveExecApprovalUnavailableInlineMock.mockReset();
|
|
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(false);
|
|
resolveExecHostApprovalContextMock.mockReset();
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
});
|
|
runExecProcessMock.mockReset();
|
|
sendExecApprovalFollowupResultMock.mockReset();
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockReset();
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) => ({
|
|
approvedByAsk: value.approvedByAsk,
|
|
deniedReason: value.deniedReason,
|
|
}));
|
|
detectInterpreterInlineEvalArgvMock.mockReset();
|
|
detectInterpreterInlineEvalArgvMock.mockReturnValue(null);
|
|
resolveExecApprovalUnavailableDecisionsMock.mockClear();
|
|
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,
|
|
});
|
|
});
|
|
|
|
function runGatewayAllowlist(
|
|
overrides: Partial<GatewayAllowlistParams> & Pick<GatewayAllowlistParams, "command">,
|
|
) {
|
|
const { command, ...rest } = overrides;
|
|
return processGatewayAllowlist({
|
|
command,
|
|
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,
|
|
...rest,
|
|
});
|
|
}
|
|
|
|
async function runTimedOutStrictInlineEval(params: {
|
|
security: "full" | "allowlist";
|
|
askFallback: "full" | "allowlist";
|
|
approvedByAsk: boolean;
|
|
}) {
|
|
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: params.security,
|
|
hostAsk: "always",
|
|
askFallback: params.askFallback,
|
|
});
|
|
detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT);
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: true },
|
|
approvedByAsk: params.approvedByAsk,
|
|
deniedReason: null,
|
|
});
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({
|
|
approvedByAsk: false,
|
|
deniedReason: "approval-timeout",
|
|
});
|
|
|
|
return runGatewayAllowlist({
|
|
command: "python3 -c 'print(1)'",
|
|
security: params.security,
|
|
ask: "always",
|
|
strictInlineEval: true,
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
}
|
|
|
|
it("still requires approval when allowlist execution plan is unavailable despite durable trust", async () => {
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("emits security events for gateway exec approval requests and denials", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "user-denied",
|
|
});
|
|
const captured = captureSecurityEvents();
|
|
|
|
let result: Awaited<ReturnType<typeof runGatewayAllowlist>>;
|
|
try {
|
|
result = await runGatewayAllowlist({
|
|
command: "deploy --token raw-secret-value",
|
|
turnSourceChannel: "webchat",
|
|
agentId: "agent-1",
|
|
});
|
|
} finally {
|
|
captured.stop();
|
|
}
|
|
|
|
expect(result!.deniedResult?.details.status).toBe("failed");
|
|
expect(captured.events).toHaveLength(2);
|
|
expect(captured.events[0]).toMatchObject({
|
|
action: "exec.approval.requested",
|
|
outcome: "success",
|
|
severity: "low",
|
|
category: "approval",
|
|
actor: { kind: "agent" },
|
|
target: { kind: "tool", name: "system.exec", owner: "gateway" },
|
|
policy: { id: "exec.approval", decision: "ask" },
|
|
control: { id: "exec.approval", family: "approval" },
|
|
attributes: {
|
|
host: "gateway",
|
|
security: "allowlist",
|
|
ask: "off",
|
|
segment_count: 1,
|
|
has_agent_id: true,
|
|
},
|
|
});
|
|
expect(captured.events[1]).toMatchObject({
|
|
action: "exec.approval.denied",
|
|
outcome: "denied",
|
|
severity: "medium",
|
|
reason: "user-denied",
|
|
policy: { id: "exec.approval", decision: "deny", reason: "user-denied" },
|
|
attributes: {
|
|
decision: "deny",
|
|
has_agent_id: true,
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(captured.events);
|
|
expect(serialized).not.toContain("deploy");
|
|
expect(serialized).not.toContain("raw-secret-value");
|
|
expect(serialized).not.toContain("agent-1");
|
|
});
|
|
|
|
it("emits a denied security event for inline unavailable approval denials", async () => {
|
|
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true);
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "user-denied",
|
|
});
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({
|
|
approvedByAsk: false,
|
|
deniedReason: "user-denied",
|
|
});
|
|
const captured = captureSecurityEvents();
|
|
|
|
try {
|
|
await expect(
|
|
runGatewayAllowlist({
|
|
command: "deploy --token raw-secret-value",
|
|
agentId: "agent-1",
|
|
}),
|
|
).rejects.toThrow("denied");
|
|
} finally {
|
|
captured.stop();
|
|
}
|
|
|
|
expect(captured.events).toHaveLength(2);
|
|
expect(captured.events[1]).toMatchObject({
|
|
action: "exec.approval.denied",
|
|
outcome: "denied",
|
|
severity: "medium",
|
|
reason: "user-denied",
|
|
policy: { id: "exec.approval", decision: "deny", reason: "user-denied" },
|
|
attributes: {
|
|
has_agent_id: true,
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(captured.events);
|
|
expect(serialized).not.toContain("deploy");
|
|
expect(serialized).not.toContain("raw-secret-value");
|
|
expect(serialized).not.toContain("agent-1");
|
|
});
|
|
|
|
it("emits an approved security event for inline unavailable approval approvals", async () => {
|
|
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true);
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
const captured = captureSecurityEvents();
|
|
|
|
let result: Awaited<ReturnType<typeof runGatewayAllowlist>>;
|
|
try {
|
|
result = await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
agentId: "agent-1",
|
|
});
|
|
} finally {
|
|
captured.stop();
|
|
}
|
|
|
|
expect(result!).toEqual({
|
|
execCommandOverride: undefined,
|
|
allowWithoutEnforcedCommand: true,
|
|
});
|
|
expect(captured.events).toHaveLength(2);
|
|
expect(captured.events[1]).toMatchObject({
|
|
action: "exec.approval.approved",
|
|
outcome: "success",
|
|
severity: "medium",
|
|
policy: { id: "exec.approval", decision: "allow" },
|
|
attributes: {
|
|
has_agent_id: true,
|
|
},
|
|
});
|
|
expect(JSON.stringify(captured.events)).not.toContain("agent-1");
|
|
});
|
|
|
|
it("auto-reviews simple read-only approval misses without prompting", async () => {
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: "echo ok",
|
|
argv: ["echo", "ok"],
|
|
host: "gateway",
|
|
reason: "approval-required",
|
|
}),
|
|
);
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({
|
|
execCommandOverride: undefined,
|
|
allowWithoutEnforcedCommand: true,
|
|
});
|
|
});
|
|
|
|
it("auto-reviews heredoc commands instead of forcing human approval", async () => {
|
|
const command = "python3 - <<'PY'\nprint('ok')\nPY";
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: true,
|
|
command,
|
|
});
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [
|
|
{
|
|
raw: command,
|
|
resolution: null,
|
|
argv: ["python3", "-", "<<'PY'"],
|
|
},
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command,
|
|
argv: ["python3", "-", "<<'PY'"],
|
|
host: "gateway",
|
|
reason: "heredoc",
|
|
}),
|
|
);
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({
|
|
execCommandOverride: undefined,
|
|
allowWithoutEnforcedCommand: true,
|
|
});
|
|
});
|
|
|
|
it("auto-reviews strict inline-eval commands instead of forcing human approval", async () => {
|
|
const command = "python3 -c 'print(1)'";
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT);
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: true,
|
|
command,
|
|
});
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [
|
|
{
|
|
raw: command,
|
|
resolution: null,
|
|
argv: ["python3", "-c", "print(1)"],
|
|
},
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
const warnings: string[] = [];
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
strictInlineEval: true,
|
|
warnings,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command,
|
|
argv: ["python3", "-c", "print(1)"],
|
|
host: "gateway",
|
|
reason: "strict-inline-eval",
|
|
analysis: expect.objectContaining({
|
|
inlineEval: true,
|
|
}),
|
|
}),
|
|
);
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(warnings[0]).toContain("reviewer or explicit approval");
|
|
expect(result).toEqual({
|
|
execCommandOverride: undefined,
|
|
allowWithoutEnforcedCommand: true,
|
|
});
|
|
});
|
|
|
|
it("uses a plan-backed enforced command when the allowlist plan is usable", async () => {
|
|
const command = "head -c 16";
|
|
const authorizationPlan = await planShellAuthorization({
|
|
command,
|
|
env: { PATH: "/usr/bin:/bin" },
|
|
});
|
|
expect(authorizationPlan.ok).toBe(true);
|
|
if (!authorizationPlan.ok) {
|
|
throw new Error(authorizationPlan.reason);
|
|
}
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ raw: command, resolution: null, argv: ["head", "-c", "16"] }],
|
|
segmentAllowlistEntries: [],
|
|
segmentSatisfiedBy: ["safeBins"],
|
|
authorizationPlan,
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "off",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
execCommandOverride: "/usr/bin/head -c 16",
|
|
});
|
|
});
|
|
|
|
it("auto-reviews allowlist plan misses instead of forcing human approval", async () => {
|
|
const command = "echo ok";
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: false,
|
|
reason: "segment execution plan unavailable",
|
|
});
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ raw: command, resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command,
|
|
argv: ["echo", "ok"],
|
|
host: "gateway",
|
|
reason: "execution-plan-miss",
|
|
}),
|
|
);
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({
|
|
execCommandOverride: undefined,
|
|
allowWithoutEnforcedCommand: true,
|
|
});
|
|
});
|
|
|
|
it("omits allow-always when allowlist execution cannot persist reusable patterns", async () => {
|
|
const command = "ls *.ts";
|
|
const authorizationPlan = await planShellAuthorization({
|
|
command,
|
|
env: { PATH: "/usr/bin:/bin" },
|
|
});
|
|
expect(authorizationPlan.ok).toBe(true);
|
|
if (!authorizationPlan.ok) {
|
|
throw new Error(authorizationPlan.reason);
|
|
}
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
hasDurableExecApprovalMock.mockReturnValue(false);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: authorizationPlan.groups.flatMap((group) =>
|
|
group.candidates.map((candidate) => candidate.sourceSegment),
|
|
),
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/ls", source: "allow-always" }],
|
|
segmentSatisfiedBy: ["allowlist"],
|
|
authorizationPlan,
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
autoReview: false,
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
expect(resolveExecApprovalAllowedDecisionsMock).toHaveBeenCalledWith({
|
|
ask: "on-miss",
|
|
allowAlwaysPersistence: {
|
|
kind: "one-shot",
|
|
reasons: ["no-reusable-pattern"],
|
|
},
|
|
});
|
|
expect(buildExecApprovalPendingToolResultMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("honors durable exact-command trust for unenforceable allowlisted commands", async () => {
|
|
const command = "ls *.ts";
|
|
const authorizationPlan = await planShellAuthorization({
|
|
command,
|
|
env: { PATH: "/usr/bin:/bin" },
|
|
});
|
|
expect(authorizationPlan.ok).toBe(true);
|
|
if (!authorizationPlan.ok) {
|
|
throw new Error(authorizationPlan.reason);
|
|
}
|
|
requiresExecApprovalMock.mockReturnValue(false);
|
|
hasDurableExecApprovalMock.mockReturnValue(true);
|
|
hasExactCommandDurableExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: authorizationPlan.groups.flatMap((group) =>
|
|
group.candidates.map((candidate) => candidate.sourceSegment),
|
|
),
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/ls", source: "allow-always" }],
|
|
segmentSatisfiedBy: ["allowlist"],
|
|
authorizationPlan,
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
autoReview: false,
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ execCommandOverride: undefined });
|
|
});
|
|
|
|
it("offers allow-always for shell-wrapper misses with reusable executable patterns", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const command = "sh -c 'git status'";
|
|
const env = { PATH: "/usr/bin:/bin" };
|
|
const approvalsFile = { version: 1, agents: {} };
|
|
const authorizationPlan = await planShellAuthorization({ command, env });
|
|
expect(authorizationPlan.ok).toBe(true);
|
|
if (!authorizationPlan.ok) {
|
|
throw new Error(authorizationPlan.reason);
|
|
}
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: authorizationPlan.groups.flatMap((group) =>
|
|
group.candidates.map((candidate) => candidate.sourceSegment),
|
|
),
|
|
segmentAllowlistEntries: [],
|
|
segmentSatisfiedBy: [null],
|
|
authorizationPlan,
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: approvalsFile },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-always");
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command,
|
|
ask: "on-miss",
|
|
env,
|
|
autoReview: false,
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
expect(resolveExecApprovalAllowedDecisionsMock).toHaveBeenCalledWith({
|
|
ask: "on-miss",
|
|
allowAlwaysPersistence: {
|
|
kind: "patterns",
|
|
commandText: "sh -c 'git status'",
|
|
patterns: [{ pattern: "/usr/bin/git", argPattern: undefined }],
|
|
},
|
|
});
|
|
expect(buildExecApprovalPendingToolResultMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
}),
|
|
);
|
|
expect(approvalsFile.agents).toEqual({
|
|
main: {
|
|
allowlist: [
|
|
expect.objectContaining({
|
|
pattern: "/usr/bin/git",
|
|
source: "allow-always",
|
|
}),
|
|
expect.objectContaining({
|
|
pattern: expect.stringMatching(/^=node-command:[0-9a-f]{16}$/),
|
|
source: "allow-always",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("requests human approval when auto-review asks on an approval miss", async () => {
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
defaultExecAutoReviewerMock.mockResolvedValue({
|
|
decision: "ask",
|
|
risk: "medium",
|
|
rationale: "needs a person",
|
|
});
|
|
const warnings: string[] = [];
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
warnings,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).toHaveBeenCalledTimes(1);
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(warnings.join("\n")).toContain("needs a person");
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("requests human approval when auto-review cannot bind a single parsed command", async () => {
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [
|
|
{ raw: "echo ok", resolution: null, argv: ["echo", "ok"] },
|
|
{ raw: "pwd", resolution: null, argv: ["pwd"] },
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo ok; pwd",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled();
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("does not use fallback-full when auto-review cannot parse the command", async () => {
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: false,
|
|
allowlistSatisfied: false,
|
|
segments: [],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "full",
|
|
});
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: true },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) =>
|
|
value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut
|
|
? { approvedByAsk: false, deniedReason: "approval-timeout" }
|
|
: { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason },
|
|
);
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo 'unterminated",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled();
|
|
expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requiresAutoReviewHumanApproval: true,
|
|
}),
|
|
);
|
|
expect(result.deniedResult?.details.status).toBe("failed");
|
|
expect(result.deniedResult?.content[0]).toEqual(
|
|
expect.objectContaining({
|
|
text: "Exec denied (gateway id=req-1, approval-timeout): echo 'unterminated",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not use fallback-full when auto-review asks for human approval", async () => {
|
|
requiresExecApprovalMock.mockReturnValue(true);
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
defaultExecAutoReviewerMock.mockResolvedValue({
|
|
decision: "ask",
|
|
risk: "medium",
|
|
rationale: "needs a person",
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "on-miss",
|
|
askFallback: "full",
|
|
});
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: true },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
|
|
enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) =>
|
|
value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut
|
|
? { approvedByAsk: false, deniedReason: "approval-timeout" }
|
|
: { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason },
|
|
);
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requiresAutoReviewHumanApproval: true,
|
|
}),
|
|
);
|
|
expect(result.deniedResult?.details.status).toBe("failed");
|
|
expect(result.deniedResult?.content[0]).toEqual(
|
|
expect.objectContaining({
|
|
text: "Exec denied (gateway id=req-1, approval-timeout): echo ok",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires approval for security audit suppression edits unless yolo mode is active", async () => {
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "openclaw config set security.audit.suppressions '[]'",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("keeps security audit suppression edits off the auto-review path", async () => {
|
|
const warnings: string[] = [];
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "openclaw config set security.audit.suppressions '[]'",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
autoReview: true,
|
|
warnings,
|
|
});
|
|
|
|
expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled();
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(warnings[0]).toContain("explicit approval");
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("does not require approval for security audit suppression edits in yolo mode", async () => {
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
await runGatewayAllowlist({
|
|
command: "openclaw config set security.audit.suppressions '[]'",
|
|
security: "full",
|
|
ask: "off",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not require suppression edit approval for read-only suppression inspection", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [
|
|
{ resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] },
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
await runGatewayAllowlist({
|
|
command: "openclaw config get security.audit.suppressions",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not require suppression edit approval for profile-scoped read-only inspection", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [
|
|
{
|
|
resolution: null,
|
|
argv: ["openclaw", "--profile", "rescue", "config", "get", "security.audit.suppressions"],
|
|
},
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
await runGatewayAllowlist({
|
|
command: "openclaw --profile rescue config get security.audit.suppressions",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires suppression edit approval when a mutating segment follows read-only inspection", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [
|
|
{ resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] },
|
|
{
|
|
resolution: null,
|
|
argv: ["openclaw", "config", "set", "security.audit.suppressions", "[]"],
|
|
},
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command:
|
|
"openclaw config get security.audit.suppressions; openclaw config set security.audit.suppressions '[]'",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("requires suppression edit approval when allowlist analysis only returns a read-only prefix", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [
|
|
{ resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] },
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command:
|
|
"openclaw config get security.audit.suppressions; openclaw config set security.audit.suppressions '[]'",
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("requires suppression edit approval when a heredoc patch follows read-only inspection", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: false,
|
|
segments: [
|
|
{
|
|
raw: "openclaw config get security.audit.suppressions",
|
|
resolution: null,
|
|
argv: ["openclaw", "config", "get", "security.audit.suppressions"],
|
|
},
|
|
{
|
|
raw: "openclaw config patch --stdin <<'EOF'",
|
|
resolution: null,
|
|
argv: ["openclaw", "config", "patch", "--stdin"],
|
|
},
|
|
],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "full",
|
|
hostAsk: "on-miss",
|
|
askFallback: "deny",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: `openclaw config get security.audit.suppressions; openclaw config patch --stdin <<'EOF'
|
|
{"security":{"audit":{"suppressions":[]}}}
|
|
EOF`,
|
|
security: "full",
|
|
ask: "on-miss",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
});
|
|
|
|
it("allows durable exact-command trust to bypass the synchronous allowlist miss", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: false,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["node", "--version"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
hasDurableExecApprovalMock.mockReturnValue(true);
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: true,
|
|
command: "node --version",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "node --version",
|
|
});
|
|
|
|
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ execCommandOverride: undefined });
|
|
});
|
|
|
|
it("keeps denying allowlist misses when durable trust does not match", async () => {
|
|
evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: false,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["node", "--version"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
hasDurableExecApprovalMock.mockReturnValue(false);
|
|
|
|
await expect(
|
|
runGatewayAllowlist({
|
|
command: "node --version",
|
|
}),
|
|
).rejects.toThrow("exec denied: allowlist miss");
|
|
});
|
|
|
|
it("uses sessionKey for followups when notifySessionKey is absent", async () => {
|
|
await runGatewayAllowlist({
|
|
command: "echo ok",
|
|
sessionKey: "agent:main:telegram:direct:123",
|
|
});
|
|
|
|
expect(requireBuildFollowupTargetInput(0).sessionKey).toBe("agent:main:telegram:direct:123");
|
|
});
|
|
|
|
it("keeps webchat diagnostics approvals as direct pasteable followups", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: null,
|
|
});
|
|
const outcome = {
|
|
status: "completed" as const,
|
|
exitCode: 0,
|
|
exitSignal: null,
|
|
durationMs: 12,
|
|
timedOut: false,
|
|
aggregated: JSON.stringify({
|
|
path: "/tmp/openclaw-diagnostics.zip",
|
|
bytes: 1234,
|
|
manifest: {
|
|
generatedAt: "2026-04-28T20:58:29.311Z",
|
|
openclawVersion: "2026.4.27",
|
|
contents: [
|
|
{ path: "diagnostics.json", bytes: 100 },
|
|
{ path: "summary.md", bytes: 200 },
|
|
],
|
|
privacy: {
|
|
payloadFree: true,
|
|
rawLogsIncluded: false,
|
|
notes: ["Logs keep operational summaries."],
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
runExecProcessMock.mockResolvedValue({
|
|
session: { id: "sess-1" },
|
|
promise: Promise.resolve(outcome),
|
|
});
|
|
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
|
|
|
|
const approvalFollowup = vi.fn<ExecApprovalFollowupFactory>(async () =>
|
|
[
|
|
"OpenAI Codex harness:",
|
|
"Codex diagnostics sent to OpenAI servers:",
|
|
"Session 1",
|
|
"Channel: telegram",
|
|
"OpenClaw session id: `session-1`",
|
|
"Codex thread id: `thread-1`",
|
|
].join("\n"),
|
|
);
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "openclaw gateway diagnostics export --json",
|
|
trigger: "diagnostics",
|
|
approvalFollowupMode: "direct",
|
|
approvalFollowup,
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(requireBuildFollowupTargetInput(0).direct).toBe(true);
|
|
|
|
const followupTarget = requireSentFollowupTarget(0);
|
|
expect(followupTarget?.direct).toBe(true);
|
|
const followupText = requireSentFollowupText(0);
|
|
expect(followupText).toContain("Diagnostics export created.");
|
|
expect(followupText).toContain("Path: /tmp/openclaw-diagnostics.zip");
|
|
expect(followupText).toContain("Contents (2 files):");
|
|
expect(followupText).toContain("OpenAI Codex harness:");
|
|
expect(followupText).toContain("Codex diagnostics sent to OpenAI servers:");
|
|
expect(followupText).toContain("Codex thread id: `thread-1`");
|
|
const approvalInput = requireApprovalFollowupInput(approvalFollowup, 0);
|
|
expect(approvalInput?.approvalId).toBe("req-1");
|
|
expect(approvalInput?.sessionId).toBe("sess-1");
|
|
expect(approvalInput?.trigger).toBe("diagnostics");
|
|
expect(approvalInput?.outcome?.status).toBe("completed");
|
|
expect(approvalInput?.outcome?.exitCode).toBe(0);
|
|
});
|
|
|
|
it("uses async agent followups for explicit webchat approval mode", async () => {
|
|
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "always",
|
|
askFallback: "deny",
|
|
});
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
runExecProcessMock.mockResolvedValue({
|
|
session: { id: "sess-1" },
|
|
promise: Promise.resolve({
|
|
status: "completed",
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
aggregated: "done",
|
|
}),
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "openclaw sessions export-trajectory --json",
|
|
approvalFollowupMode: "agent",
|
|
sessionId: "approval-session",
|
|
sessionStore: "/tmp/openclaw-sessions.json",
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(requireBuildFollowupTargetInput(0)).toMatchObject({
|
|
direct: false,
|
|
expectedSessionId: "approval-session",
|
|
sessionStore: "/tmp/openclaw-sessions.json",
|
|
});
|
|
expect(requireSentFollowupTarget(0)?.direct).toBe(false);
|
|
expect(requireSentFollowupText(0)).toContain("done");
|
|
});
|
|
|
|
it("waits inline for webchat approval so the exec tool can return real output to the model", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "pwd && df -h",
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(result.pendingResult).toBeUndefined();
|
|
expect(result.deniedResult).toBeUndefined();
|
|
expect(result.allowWithoutEnforcedCommand).toBe(true);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
expect(buildExecApprovalFollowupTargetMock).not.toHaveBeenCalled();
|
|
expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["telegram"],
|
|
["slack"],
|
|
["discord"],
|
|
["signal"],
|
|
["whatsapp"],
|
|
["imessage"],
|
|
["matrix"],
|
|
["googlechat"],
|
|
["qqbot"],
|
|
])(
|
|
"waits inline for native chat approval (%s) so the exec tool returns real output (issue #93918)",
|
|
async (turnSourceChannel) => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "find . -maxdepth 1",
|
|
turnSourceChannel,
|
|
});
|
|
|
|
expect(result.pendingResult).toBeUndefined();
|
|
expect(result.deniedResult).toBeUndefined();
|
|
expect(result.allowWithoutEnforcedCommand).toBe(true);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
expect(buildExecApprovalFollowupTargetMock).not.toHaveBeenCalled();
|
|
expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["telegram"],
|
|
["slack"],
|
|
["discord"],
|
|
["signal"],
|
|
["whatsapp"],
|
|
["imessage"],
|
|
["matrix"],
|
|
["googlechat"],
|
|
["qqbot"],
|
|
])(
|
|
"returns native chat approval denials (%s) as the foreground tool result (issue #93918)",
|
|
async (turnSourceChannel) => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "user-denied",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "find . -maxdepth 1",
|
|
turnSourceChannel,
|
|
});
|
|
|
|
expect(result.pendingResult).toBeUndefined();
|
|
expect(result.deniedResult?.details.status).toBe("failed");
|
|
expect(result.deniedResult?.content[0]).toEqual(
|
|
expect.objectContaining({
|
|
text: "Exec denied (gateway id=req-1, user-denied): find . -maxdepth 1",
|
|
}),
|
|
);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("keeps the fire-and-forget path for channels without native approval clients", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
runExecProcessMock.mockResolvedValue({
|
|
session: { id: "sess-1" },
|
|
promise: Promise.resolve({
|
|
status: "completed",
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
aggregated: "done",
|
|
}),
|
|
});
|
|
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "find . -maxdepth 1",
|
|
turnSourceChannel: "feishu",
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(runExecProcessMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps the fire-and-forget path for headless cron approval followups", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: true,
|
|
deniedReason: null,
|
|
});
|
|
runExecProcessMock.mockResolvedValue({
|
|
session: { id: "sess-1" },
|
|
promise: Promise.resolve({
|
|
status: "completed",
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
aggregated: "done",
|
|
}),
|
|
});
|
|
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "find . -maxdepth 1",
|
|
turnSourceChannel: "telegram",
|
|
approvalFollowupMode: "agent",
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(requireBuildFollowupTargetInput(0).direct).toBe(false);
|
|
});
|
|
|
|
it("returns webchat approval denials as the foreground tool result", async () => {
|
|
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny");
|
|
createExecApprovalDecisionStateMock.mockReturnValue({
|
|
baseDecision: { timedOut: false },
|
|
approvedByAsk: false,
|
|
deniedReason: "user-denied",
|
|
});
|
|
|
|
const result = await runGatewayAllowlist({
|
|
command: "pwd && df -h",
|
|
turnSourceChannel: "webchat",
|
|
});
|
|
|
|
expect(result.pendingResult).toBeUndefined();
|
|
expect(result.deniedResult?.details.status).toBe("failed");
|
|
expect(result.deniedResult?.content[0]).toEqual(
|
|
expect.objectContaining({
|
|
text: "Exec denied (gateway id=req-1, user-denied): pwd && df -h",
|
|
}),
|
|
);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("denies timed-out inline-eval requests instead of auto-running them", async () => {
|
|
const result = await runTimedOutStrictInlineEval({
|
|
security: "full",
|
|
askFallback: "full",
|
|
approvedByAsk: true,
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
approvalId: "req-1",
|
|
sessionKey: "agent:main:main",
|
|
turnSourceChannel: undefined,
|
|
direct: false,
|
|
}),
|
|
"Exec denied (gateway id=req-1, approval-timeout): python3 -c 'print(1)'",
|
|
);
|
|
});
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("denies allowlist timeout fallback for strict inline-eval commands", async () => {
|
|
const result = await runTimedOutStrictInlineEval({
|
|
security: "allowlist",
|
|
askFallback: "allowlist",
|
|
approvedByAsk: false,
|
|
});
|
|
|
|
expect(result.pendingResult?.details.status).toBe("approval-pending");
|
|
await vi.waitFor(() => {
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
approvalId: "req-1",
|
|
sessionKey: "agent:main:main",
|
|
turnSourceChannel: undefined,
|
|
direct: false,
|
|
}),
|
|
"Exec denied (gateway id=req-1, approval-timeout): python3 -c 'print(1)'",
|
|
);
|
|
});
|
|
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
|
|
expect(runExecProcessMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|