mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-07 15:21:06 +00:00
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn());
|
|
const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn());
|
|
const buildExecApprovalFollowupTargetMock = vi.hoisted(() => vi.fn(() => null));
|
|
const evaluateShellAllowlistMock = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
|
|
})),
|
|
);
|
|
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
|
|
const buildEnforcedShellCommandMock = vi.hoisted(() =>
|
|
vi.fn((): { ok: boolean; reason?: string; command?: string } => ({
|
|
ok: false,
|
|
reason: "segment execution plan unavailable",
|
|
})),
|
|
);
|
|
const recordAllowlistMatchesUseMock = vi.hoisted(() => vi.fn());
|
|
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
})),
|
|
);
|
|
|
|
vi.mock("../infra/exec-approvals.js", () => ({
|
|
evaluateShellAllowlist: evaluateShellAllowlistMock,
|
|
hasDurableExecApproval: hasDurableExecApprovalMock,
|
|
buildEnforcedShellCommand: buildEnforcedShellCommandMock,
|
|
requiresExecApproval: vi.fn(() => false),
|
|
recordAllowlistUse: vi.fn(),
|
|
recordAllowlistMatchesUse: recordAllowlistMatchesUseMock,
|
|
resolveApprovalAuditCandidatePath: vi.fn(() => null),
|
|
resolveAllowAlwaysPatterns: vi.fn(() => []),
|
|
resolveExecApprovalAllowedDecisions: vi.fn(() => ["allow-once", "allow-always", "deny"]),
|
|
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: resolveExecHostApprovalContextMock,
|
|
buildDefaultExecApprovalRequestArgs: vi.fn(() => ({})),
|
|
buildHeadlessExecApprovalDeniedMessage: vi.fn(() => "denied"),
|
|
buildExecApprovalFollowupTarget: buildExecApprovalFollowupTargetMock,
|
|
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", () => {
|
|
beforeAll(async () => {
|
|
({ processGatewayAllowlist } = await import("./bash-tools.exec-host-gateway.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
buildExecApprovalPendingToolResultMock.mockReset();
|
|
buildExecApprovalFollowupTargetMock.mockReset();
|
|
buildExecApprovalFollowupTargetMock.mockReturnValue(null);
|
|
evaluateShellAllowlistMock.mockReset();
|
|
evaluateShellAllowlistMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: true,
|
|
allowlistSatisfied: true,
|
|
segments: [{ resolution: null, argv: ["echo", "ok"] }],
|
|
segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
|
|
});
|
|
hasDurableExecApprovalMock.mockReset();
|
|
hasDurableExecApprovalMock.mockReturnValue(true);
|
|
buildEnforcedShellCommandMock.mockReset();
|
|
buildEnforcedShellCommandMock.mockReturnValue({
|
|
ok: false,
|
|
reason: "segment execution plan unavailable",
|
|
});
|
|
recordAllowlistMatchesUseMock.mockReset();
|
|
resolveExecHostApprovalContextMock.mockReset();
|
|
resolveExecHostApprovalContextMock.mockReturnValue({
|
|
approvals: { allowlist: [], file: { version: 1, agents: {} } },
|
|
hostSecurity: "allowlist",
|
|
hostAsk: "off",
|
|
askFallback: "deny",
|
|
});
|
|
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,
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("allows durable exact-command trust to bypass the synchronous allowlist miss", async () => {
|
|
evaluateShellAllowlistMock.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 processGatewayAllowlist({
|
|
command: "node --version",
|
|
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).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ execCommandOverride: undefined });
|
|
});
|
|
|
|
it("keeps denying allowlist misses when durable trust does not match", async () => {
|
|
evaluateShellAllowlistMock.mockReturnValue({
|
|
allowlistMatches: [],
|
|
analysisOk: false,
|
|
allowlistSatisfied: false,
|
|
segments: [{ resolution: null, argv: ["node", "--version"] }],
|
|
segmentAllowlistEntries: [],
|
|
});
|
|
hasDurableExecApprovalMock.mockReturnValue(false);
|
|
|
|
await expect(
|
|
processGatewayAllowlist({
|
|
command: "node --version",
|
|
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,
|
|
}),
|
|
).rejects.toThrow("exec denied: allowlist miss");
|
|
});
|
|
|
|
it("uses sessionKey for followups when notifySessionKey is absent", async () => {
|
|
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,
|
|
sessionKey: "agent:main:telegram:direct:123",
|
|
});
|
|
|
|
expect(buildExecApprovalFollowupTargetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey: "agent:main:telegram:direct:123",
|
|
}),
|
|
);
|
|
});
|
|
});
|