mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 03:36:11 +00:00
* fix(codex): avoid guardian review for local models * fix(codex): route app-server auto exec review * fix(codex): make guardian requirements provider-aware * fix(codex): block unrouted bound approvals * fix(channels): satisfy ingress queue lint * fix(codex): use local-model policy for side forks * fix(extensions): satisfy ingress lint * fix(codex): require trusted exec reviewer model * fix(exec): share control command approval guards * fix(codex): fail closed for unknown guardian model provider * fix(codex): reject custom exec reviewer endpoints * fix(codex): preserve bound providers on app-server reuse * fix(codex): prefer qualified app-server model providers * fix(codex): preserve guardian on model control switches * fix(codex): retain local providers across model switches * fix(codex): distrust aliased reviewer model refs * fix(codex): preserve providers after thread rotation * fix(codex): clear stale providers on qualified model switches * fix(codex): prefer qualified models over legacy providers * fix(codex): validate reviewer trust before auto approvals * fix(codex): recompute reviewer policy after binding rotation * fix(codex): normalize reviewer aliases before trust checks * fix(codex): retain bound providers for slashed local models * fix(codex): normalize provider trust checks for exec review * fix(codex): ignore stale bindings for explicit providers * fix(codex): share trusted reviewer endpoint policy * fix(codex): keep network approvals on plugin path * fix(codex): route provider-qualified model refs * fix(codex): reject blank masked OpenAI base overrides * fix(codex): scope exec reviewer alias trust * fix(codex): distrust exec reviewer transport overrides
2563 lines
83 KiB
TypeScript
2563 lines
83 KiB
TypeScript
// Codex tests cover approval bridge plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { reviewExecRequestWithConfiguredModel } from "openclaw/plugin-sdk/agent-harness-exec-review-runtime";
|
|
import {
|
|
callGatewayTool,
|
|
hasNativeHookRelayInvocation,
|
|
invokeNativeHookRelay,
|
|
resolveNativeHookRelayDeferredToolApproval,
|
|
runBeforeToolCallHook,
|
|
type EmbeddedRunAttemptParams,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
|
|
|
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
|
|
callGatewayTool: vi.fn(),
|
|
hasNativeHookRelayInvocation: vi.fn(() => false),
|
|
invokeNativeHookRelay: vi.fn(),
|
|
resolveNativeHookRelayDeferredToolApproval: vi.fn(),
|
|
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
|
|
blocked: false,
|
|
params,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/agent-harness-exec-review-runtime", async (importOriginal) => ({
|
|
...(await importOriginal<
|
|
typeof import("openclaw/plugin-sdk/agent-harness-exec-review-runtime")
|
|
>()),
|
|
reviewExecRequestWithConfiguredModel: vi.fn(),
|
|
}));
|
|
|
|
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
|
const mockHasNativeHookRelayInvocation = vi.mocked(hasNativeHookRelayInvocation);
|
|
const mockInvokeNativeHookRelay = vi.mocked(invokeNativeHookRelay);
|
|
const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
|
|
resolveNativeHookRelayDeferredToolApproval,
|
|
);
|
|
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
|
|
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`Expected ${label}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function gatewayCallAt(callIndex = 0) {
|
|
const call = mockCallGatewayTool.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`Expected gateway call ${callIndex + 1}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function gatewayRequestPayload(callIndex = 0) {
|
|
return requireRecord(gatewayCallAt(callIndex)[2], `gateway request payload ${callIndex + 1}`);
|
|
}
|
|
|
|
function gatewayCallOptions(callIndex = 0) {
|
|
return gatewayCallAt(callIndex)[3];
|
|
}
|
|
|
|
function gatewayCallMethod(callIndex = 0) {
|
|
return gatewayCallAt(callIndex)[0];
|
|
}
|
|
|
|
function findApprovalEvent(
|
|
params: EmbeddedRunAttemptParams,
|
|
fields: {
|
|
status?: string;
|
|
approvalId?: string;
|
|
command?: string;
|
|
reason?: string;
|
|
message?: string;
|
|
},
|
|
) {
|
|
const onAgentEvent = params.onAgentEvent as unknown as { mock?: { calls?: unknown[][] } };
|
|
const calls = onAgentEvent.mock?.calls;
|
|
if (!Array.isArray(calls)) {
|
|
throw new Error("Expected onAgentEvent mock calls");
|
|
}
|
|
for (const call of calls) {
|
|
const event = requireRecord(call[0], "agent event");
|
|
if (event.stream !== "approval") {
|
|
continue;
|
|
}
|
|
const data = requireRecord(event.data, "approval event data");
|
|
if (
|
|
(!fields.status || data.status === fields.status) &&
|
|
(!fields.approvalId || data.approvalId === fields.approvalId) &&
|
|
(!fields.command || data.command === fields.command) &&
|
|
(!fields.reason || data.reason === fields.reason) &&
|
|
(!fields.message || data.message === fields.message)
|
|
) {
|
|
return data;
|
|
}
|
|
}
|
|
throw new Error(`Expected approval event ${JSON.stringify(fields)}`);
|
|
}
|
|
|
|
function createParams(): EmbeddedRunAttemptParams {
|
|
return {
|
|
sessionKey: "agent:main:session-1",
|
|
agentId: "main",
|
|
messageChannel: "telegram",
|
|
currentChannelId: "chat-1",
|
|
agentAccountId: "default",
|
|
currentThreadTs: "thread-ts",
|
|
onAgentEvent: vi.fn(),
|
|
} as unknown as EmbeddedRunAttemptParams;
|
|
}
|
|
|
|
describe("Codex app-server approval bridge", () => {
|
|
beforeEach(() => {
|
|
mockCallGatewayTool.mockReset();
|
|
mockHasNativeHookRelayInvocation.mockReset();
|
|
mockHasNativeHookRelayInvocation.mockReturnValue(false);
|
|
mockInvokeNativeHookRelay.mockReset();
|
|
mockResolveNativeHookRelayDeferredToolApproval.mockReset();
|
|
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValue(undefined);
|
|
mockReviewExecRequestWithConfiguredModel.mockReset();
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValue({
|
|
decision: "ask",
|
|
rationale: "test reviewer asks for approval",
|
|
risk: "unknown",
|
|
});
|
|
mockRunBeforeToolCallHook.mockReset();
|
|
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
|
|
blocked: false,
|
|
params,
|
|
}));
|
|
});
|
|
|
|
it("auto-accepts app-server command approvals in yolo mode without opening plugin approvals", async () => {
|
|
const params = createParams();
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-yolo",
|
|
command: "/bin/bash -lc 'node -v'",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
autoApprove: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "acceptForSession" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolName: "exec",
|
|
approvalMode: "request",
|
|
}),
|
|
);
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
message: "Codex app-server approval auto-approved by runtime policy.",
|
|
});
|
|
});
|
|
|
|
it("auto-accepts app-server file approvals in yolo mode without opening plugin approvals", async () => {
|
|
const params = createParams();
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/fileChange/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "patch-yolo",
|
|
reason: "needs write access",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
autoApprove: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "acceptForSession" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
reason: "needs write access",
|
|
message: "Codex app-server approval auto-approved by runtime policy.",
|
|
});
|
|
});
|
|
|
|
it("routes command approvals through plugin approvals and accepts allowed commands", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-1", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-1", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-1",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
expect(gatewayCallMethod()).toBe("plugin.approval.request");
|
|
expect(typeof gatewayCallAt(0)[1]).toBe("object");
|
|
const requestPayload = gatewayRequestPayload();
|
|
expect(requestPayload.pluginId).toBe("openclaw-codex-app-server");
|
|
expect(requestPayload.title).toBe("Codex app-server command approval");
|
|
expect(requestPayload.twoPhase).toBe(true);
|
|
expect(requestPayload.turnSourceChannel).toBe("telegram");
|
|
expect(requestPayload.turnSourceTo).toBe("chat-1");
|
|
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
|
|
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith({
|
|
toolName: "exec",
|
|
params: {
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
approval: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-1",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
},
|
|
},
|
|
toolCallId: "cmd-1",
|
|
approvalMode: "request",
|
|
signal: undefined,
|
|
ctx: {
|
|
agentId: "main",
|
|
sessionKey: "agent:main:session-1",
|
|
channelId: "chat-1",
|
|
},
|
|
});
|
|
findApprovalEvent(params, { status: "pending", approvalId: "plugin:approval-1" });
|
|
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" });
|
|
});
|
|
|
|
it("uses the configured OpenClaw exec auto-review model before plugin approvals", async () => {
|
|
const params = createParams();
|
|
params.workspaceDir = "/workspace";
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
timeoutMs: 12_000,
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "read-only version check",
|
|
risk: "low",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
execReviewerAgentId: "main",
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith({
|
|
cfg: params.config,
|
|
agentId: "main",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
timeoutMs: 12_000,
|
|
},
|
|
input: {
|
|
command: "node --version",
|
|
argv: ["node", "--version"],
|
|
cwd: "/workspace",
|
|
envKeys: undefined,
|
|
host: "codex-app-server",
|
|
reason: "approval-required",
|
|
analysis: {
|
|
parsed: true,
|
|
allowlistMatched: false,
|
|
inlineEval: false,
|
|
},
|
|
agent: {
|
|
id: "main",
|
|
sessionKey: "agent:main:session-1",
|
|
},
|
|
},
|
|
});
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
message:
|
|
"Codex app-server command approval granted by OpenClaw exec auto-reviewer: read-only version check",
|
|
});
|
|
});
|
|
|
|
it("falls back to plugin approval when no exec auto-review model is configured", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-missing",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("falls back to plugin approval for managed-network command approvals", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "network request looks fine",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-network", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-network", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-network",
|
|
command: "curl https://example.test",
|
|
networkApprovalContext: {
|
|
host: "example.test",
|
|
port: 443,
|
|
protocol: "https",
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it.each(["lmstudio/local-model", "local-model"])(
|
|
"falls back to plugin approval for unsafe exec auto-review model %s",
|
|
async (model) => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model,
|
|
},
|
|
},
|
|
},
|
|
} as unknown as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "unsafe self review",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-local",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "provider base URL",
|
|
reviewerModel: "openai/gpt-5.5-mini",
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "http://127.0.0.1:11434/v1",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "provider key casing with custom base URL",
|
|
reviewerModel: "openai/gpt-5.5-mini",
|
|
models: {
|
|
providers: {
|
|
OpenAI: {
|
|
baseUrl: "http://localhost:8080/v1",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "provider local service",
|
|
reviewerModel: "openai/gpt-5.5-mini",
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
localService: { command: "local-openai-compatible" },
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "model base URL",
|
|
reviewerModel: "openai/gpt-5.5-mini@work",
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
models: [
|
|
{
|
|
id: "gpt-5.5-mini",
|
|
name: "Local GPT-compatible reviewer",
|
|
baseUrl: "http://localhost:8080/v1",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])(
|
|
"falls back to plugin approval for OpenAI reviewer with custom $name",
|
|
async ({ models, reviewerModel }) => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: reviewerModel,
|
|
},
|
|
},
|
|
},
|
|
models,
|
|
} as unknown as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "custom endpoint reviewer",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-custom-openai",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("falls back to plugin approval when an OpenAI-looking reviewer is a configured model alias", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"lmstudio/local-reviewer": {
|
|
alias: "OpenAI/Reviewer",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/reviewer@work",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "aliased local reviewer",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-aliased-openai",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("keeps exec auto-review when only an agent-specific alias matches the OpenAI reviewer", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "sidecar",
|
|
models: {
|
|
"lmstudio/local-reviewer": {
|
|
alias: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini@work",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "real OpenAI reviewer",
|
|
risk: "low",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-agent-alias",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
execReviewerAgentId: "main",
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cfg: params.config,
|
|
agentId: "main",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini@work",
|
|
},
|
|
}),
|
|
);
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to plugin approval when OpenAI reviewer uses a custom environment base URL", async () => {
|
|
const params = createParams();
|
|
vi.stubEnv("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "custom env endpoint reviewer",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-env-openai",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("falls back to plugin approval when Codex native OpenAI config uses a local base URL", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-approval-"));
|
|
try {
|
|
await fs.mkdir(path.join(tempDir, "codex-home"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(tempDir, "codex-home", "config.toml"),
|
|
'openai_base_url = "http://127.0.0.1:11434/v1"\n',
|
|
);
|
|
const params = createParams();
|
|
params.agentDir = tempDir;
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "custom native endpoint reviewer",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-native-openai",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps permission amendment command approvals on the plugin approval route", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "safe command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-amendment", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-amendment", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-amendment",
|
|
command: "node --version",
|
|
additionalPermissions: {
|
|
network: {
|
|
allowHosts: ["example.com"],
|
|
},
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
expect(gatewayRequestPayload().description).toContain("Additional permissions: network");
|
|
});
|
|
|
|
it("keeps object-shaped execpolicy amendment command approvals on the plugin approval route", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "safe command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", decision: "allow-always" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-execpolicy-object",
|
|
command: "node --version",
|
|
proposedExecpolicyAmendment: {
|
|
permissions: [{ permission: "allow", command: ["node"] }],
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "acceptForSession" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("keeps unbound shell command approvals on the plugin approval route", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "safe command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-unbound", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-unbound", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-unbound",
|
|
command: "node --version && echo ok",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"/approve abc123 allow-once",
|
|
"bash -lc '/approve abc123 allow-once'",
|
|
"openclaw channels login --channel whatsapp",
|
|
"sudo -EH bash -lc 'openclaw channels login --channel whatsapp'",
|
|
])("keeps unsafe control command approvals on the plugin approval route: %s", async (command) => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "unsafe control command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-control-command", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-control-command", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-control-command",
|
|
command,
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("keeps security audit suppression edits on the plugin approval route", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "safe command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-security-suppression", status: "accepted" })
|
|
.mockResolvedValueOnce({
|
|
id: "plugin:approval-security-suppression",
|
|
decision: "allow-once",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-security-suppression",
|
|
command: "openclaw config set security.audit.suppressions '[]'",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("keeps amendment-only decision command approvals on the plugin approval route", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "allow-once",
|
|
rationale: "safe command",
|
|
risk: "low",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", decision: "allow-always" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-amendment-only",
|
|
command: "node --version",
|
|
availableDecisions: [
|
|
{
|
|
acceptWithExecpolicyAmendment: {
|
|
patterns: ["node"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
decision: {
|
|
acceptWithExecpolicyAmendment: {
|
|
patterns: ["node"],
|
|
},
|
|
},
|
|
});
|
|
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("falls back to plugin approval when the exec auto-review model asks", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: { primary: "openai/gpt-5.5-mini" },
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
|
|
decision: "ask",
|
|
rationale: "needs human review",
|
|
risk: "medium",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-ask",
|
|
command: "git status",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledTimes(1);
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("cancels command approvals when the run aborts during exec auto-review", async () => {
|
|
const params = createParams();
|
|
params.config = {
|
|
tools: {
|
|
exec: {
|
|
mode: "auto",
|
|
reviewer: {
|
|
model: "openai/gpt-5.5-mini",
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedRunAttemptParams["config"];
|
|
const abortController = new AbortController();
|
|
mockReviewExecRequestWithConfiguredModel.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
setTimeout(
|
|
() =>
|
|
resolve({
|
|
decision: "allow-once",
|
|
rationale: "late allow",
|
|
risk: "low",
|
|
}),
|
|
50,
|
|
);
|
|
}),
|
|
);
|
|
|
|
const resultPromise = handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-auto-review-abort",
|
|
command: "node --version",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
execPolicy: { mode: "auto" },
|
|
internalExecAutoReview: true,
|
|
signal: abortController.signal,
|
|
});
|
|
abortController.abort(new Error("run stopped"));
|
|
|
|
await expect(resultPromise).resolves.toEqual({ decision: "cancel" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "failed",
|
|
message: "Codex app-server approval cancelled because the run stopped.",
|
|
});
|
|
});
|
|
|
|
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
|
|
const params = createParams();
|
|
params.messageChannel = "telegram";
|
|
params.messageProvider = "telegram";
|
|
params.currentChannelId = "telegram:-100123";
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-prefixed",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ctx: expect.objectContaining({
|
|
channelId: "-100123",
|
|
}),
|
|
}),
|
|
);
|
|
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
|
|
});
|
|
|
|
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {
|
|
const params = createParams();
|
|
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
|
blocked: true,
|
|
kind: "veto",
|
|
deniedReason: "plugin-before-tool-call",
|
|
reason: "blocked by policy",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-blocked",
|
|
command: "cat /tmp/private_key",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, { status: "denied" });
|
|
});
|
|
|
|
it("routes command approvals through the active native hook relay before prompting", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
|
stdout: `${JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: "blocked by native relay",
|
|
},
|
|
})}\n`,
|
|
stderr: "",
|
|
exitCode: 0,
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay",
|
|
command: "cat /tmp/private_key",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledWith({
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: "pre_tool_use",
|
|
rawPayload: {
|
|
hook_event_name: "PreToolUse",
|
|
openclaw_approval_mode: "report",
|
|
tool_name: "exec_command",
|
|
tool_use_id: "cmd-native-relay",
|
|
cwd: "/workspace",
|
|
turn_id: "turn-1",
|
|
tool_input: {
|
|
command: "cat /tmp/private_key",
|
|
cwd: "/workspace",
|
|
approval: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay",
|
|
command: "cat /tmp/private_key",
|
|
cwd: "/workspace",
|
|
},
|
|
cmd: "cat /tmp/private_key",
|
|
},
|
|
},
|
|
requireGeneration: true,
|
|
});
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message: "blocked by native relay",
|
|
});
|
|
});
|
|
|
|
it("falls through to plugin approval when the native hook relay has no decision", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-noop", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-noop", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-noop",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
|
expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({
|
|
relayId: "relay-1",
|
|
toolUseId: "cmd-native-relay-noop",
|
|
signal: undefined,
|
|
});
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
findApprovalEvent(params, {
|
|
status: "pending",
|
|
approvalId: "plugin:approval-native-noop",
|
|
});
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
approvalId: "plugin:approval-native-noop",
|
|
});
|
|
});
|
|
|
|
it("does not invoke the app-server relay when native PreToolUse already ran", async () => {
|
|
const params = createParams();
|
|
mockHasNativeHookRelayInvocation.mockReturnValueOnce(true);
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-observed", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-native-observed", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-observed",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockHasNativeHookRelayInvocation).toHaveBeenCalledWith({
|
|
relayId: "relay-1",
|
|
event: "pre_tool_use",
|
|
toolUseId: "cmd-native-relay-observed",
|
|
});
|
|
expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({
|
|
relayId: "relay-1",
|
|
toolUseId: "cmd-native-relay-observed",
|
|
signal: undefined,
|
|
});
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("accepts command approvals from deferred native PreToolUse plugin approvals", async () => {
|
|
const params = createParams();
|
|
mockHasNativeHookRelayInvocation.mockReturnValueOnce(true);
|
|
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValueOnce({
|
|
handled: true,
|
|
outcome: "approved-once",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-deferred",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).not.toHaveBeenCalled();
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
message: "Codex app-server approval granted for this turn.",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the native hook relay returns unreadable approval output", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
|
stdout: "not-json",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-unreadable",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message:
|
|
"OpenClaw native hook relay returned an unreadable Codex app-server approval result.",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the native hook relay returns a non-deny decision", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
|
stdout:
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "allow",
|
|
},
|
|
}) + "\n",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-allow",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message: "OpenClaw native hook relay returned a non-deny Codex app-server approval decision.",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the native hook relay exits non-zero", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
|
stdout: "ignored stdout",
|
|
stderr: "blocked from stderr",
|
|
exitCode: 1,
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-exit",
|
|
command: "pnpm test extensions/codex/src/app-server",
|
|
cwd: "/workspace",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message: "blocked from stderr",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the expected native hook relay cannot be invoked", async () => {
|
|
const params = createParams();
|
|
mockInvokeNativeHookRelay.mockRejectedValueOnce(new Error("native hook relay not found"));
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-native-relay-missing",
|
|
command: "cat /tmp/private_key",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay: {
|
|
relayId: "relay-missing",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use"],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
|
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message:
|
|
"OpenClaw native hook relay unavailable for Codex app-server approval: native hook relay not found",
|
|
});
|
|
});
|
|
|
|
it("keeps non-command approvals on the app-server approval route when a native relay is registered", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:file-approval", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:file-approval", decision: "allow-once" })
|
|
.mockResolvedValueOnce({ id: "plugin:permission-approval", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:permission-approval", decision: "deny" });
|
|
const nativeHookRelay = {
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
allowedEvents: ["pre_tool_use" as const],
|
|
};
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/fileChange/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "patch-native-relay-registered",
|
|
reason: "needs write access",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay,
|
|
});
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "permission-native-relay-registered",
|
|
permissions: {
|
|
network: { allowHosts: ["example.com"] },
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
nativeHookRelay,
|
|
});
|
|
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("denies command approvals when OpenClaw tool policy rewrites params", async () => {
|
|
const params = createParams();
|
|
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
|
blocked: false,
|
|
params: {
|
|
command: "echo rewritten",
|
|
approval: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-rewritten",
|
|
command: "echo rewritten",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-rewritten",
|
|
command: "cat /tmp/private_key",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message:
|
|
"OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.",
|
|
});
|
|
});
|
|
|
|
it("keeps OpenClaw plugin allow-always approvals scoped to one Codex request", async () => {
|
|
const params = createParams();
|
|
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
|
blocked: false,
|
|
params: {
|
|
command: "pnpm test",
|
|
approval: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-needs-approval",
|
|
command: "pnpm test",
|
|
},
|
|
},
|
|
approvalResolution: "allow-always",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-needs-approval",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "approved",
|
|
message: "Codex app-server approval granted for this turn.",
|
|
});
|
|
});
|
|
|
|
it("denies command approvals when OpenClaw tool policy requires approval", async () => {
|
|
const params = createParams();
|
|
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
|
blocked: true,
|
|
kind: "failure",
|
|
deniedReason: "plugin-approval",
|
|
reason: "Plugin approval required",
|
|
});
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-needs-approval",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
message: "Plugin approval required",
|
|
});
|
|
});
|
|
|
|
it("describes command approvals from parsed command actions when available", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-actions", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-actions", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-actions",
|
|
command: "bash -lc 'pnpm test extensions/codex'",
|
|
commandActions: [{ command: "pnpm test extensions/codex" }],
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const requestPayload = gatewayRequestPayload();
|
|
expect(String(requestPayload.description)).toContain("Command: pnpm test extensions/codex");
|
|
expect(String(requestPayload.description)).not.toContain("bash -lc");
|
|
expect(mockRunBeforeToolCallHook.mock.calls.at(0)?.[0]).toMatchObject({
|
|
toolName: "exec",
|
|
params: {
|
|
command: "bash -lc 'pnpm test extensions/codex'",
|
|
},
|
|
});
|
|
findApprovalEvent(params, { command: "pnpm test extensions/codex" });
|
|
});
|
|
|
|
it("describes command approval permission and policy amendments", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-command-permissions", status: "accepted" })
|
|
.mockResolvedValueOnce({
|
|
id: "plugin:approval-command-permissions",
|
|
decision: "allow-always",
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-permissions",
|
|
command: "npm install",
|
|
additionalPermissions: {
|
|
network: { enabled: true },
|
|
fileSystem: {
|
|
write: ["/"],
|
|
},
|
|
},
|
|
proposedExecpolicyAmendment: ["npm install"],
|
|
proposedNetworkPolicyAmendments: [{ host: "registry.npmjs.org", action: "allow" }],
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "acceptForSession" });
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("Command: npm install");
|
|
expect(description).toContain("Additional permissions: network, fileSystem");
|
|
expect(description).toContain("High-risk targets: network access, filesystem root");
|
|
expect(description).toContain("Network enabled: true");
|
|
expect(description).toContain("File system write: /");
|
|
expect(description).toContain("Proposed exec policy: npm install");
|
|
expect(description).toContain("Proposed network policy: allow registry.npmjs.org");
|
|
});
|
|
|
|
it("keeps command approval permission details visible after long command previews", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-long-command-permissions", status: "accepted" })
|
|
.mockResolvedValueOnce({
|
|
id: "plugin:approval-long-command-permissions",
|
|
decision: "allow-always",
|
|
});
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-long-permissions",
|
|
command: `${"npm install ".repeat(500)} --unsafe-perm`,
|
|
additionalPermissions: {
|
|
network: { enabled: true },
|
|
fileSystem: {
|
|
write: ["/"],
|
|
},
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("[preview truncated or unsafe content omitted]");
|
|
expect(description).toContain("Additional permissions: network, fileSystem");
|
|
expect(description).toContain("High-risk targets: network access, filesystem root");
|
|
});
|
|
|
|
it("sanitizes command previews before forwarding approval text and events", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-sanitized-command", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-sanitized-command", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-sanitized",
|
|
command: ["pnpm", "test\n--watch", "\u001b[31mextensions/codex/src/app-server\u001b[0m"],
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(gatewayRequestPayload().description).toBe(
|
|
"Command: pnpm test --watch extensions/codex/src/app-server\nSession: agent:main:session-1",
|
|
);
|
|
findApprovalEvent(params, {
|
|
status: "pending",
|
|
command: "pnpm test --watch extensions/codex/src/app-server",
|
|
});
|
|
});
|
|
|
|
it("escapes command approval previews before forwarding approval text and events", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-escaped-command", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-escaped-command", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-escaped",
|
|
command: "printf '<@U123> [trusted](https://evil) @here'",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain(
|
|
"printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'",
|
|
);
|
|
expect(description).not.toContain("<@U123>");
|
|
expect(description).not.toContain("[trusted](https://evil)");
|
|
expect(description).not.toContain("@here");
|
|
findApprovalEvent(params, {
|
|
command: "printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'",
|
|
});
|
|
});
|
|
|
|
it("preserves visible OSC-8 link labels in command previews", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-osc", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-osc", decision: "allow-once" });
|
|
const esc = "\u001b";
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-osc",
|
|
command: `prefix ${esc}]8;;https://example.com${esc}\\VISIBLE${esc}]8;;${esc}\\ suffix`,
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(gatewayRequestPayload().description).toBe(
|
|
"Command: prefix VISIBLE suffix\nSession: agent:main:session-1",
|
|
);
|
|
findApprovalEvent(params, { command: "prefix VISIBLE suffix" });
|
|
});
|
|
|
|
it("strips bidi and invisible formatting controls from command previews", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-bidi", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-bidi", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-bidi",
|
|
command: "echo safe\u202e cod.exe\u2066 hidden\u2069 \ufeffdone\u{e0100}",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(gatewayRequestPayload().description).toBe(
|
|
"Command: echo safe cod.exe hidden done\nSession: agent:main:session-1",
|
|
);
|
|
findApprovalEvent(params, { command: "echo safe cod.exe hidden done" });
|
|
});
|
|
|
|
it("marks oversized unsafe command previews as omitted", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-omitted-command", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-omitted-command", decision: "allow-once" });
|
|
const esc = "\u001b";
|
|
const oversizedPrefix = `${esc}]8;;https://example.com${esc}\\`.repeat(300);
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-omitted",
|
|
command: [oversizedPrefix, "TAIL"],
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(gatewayRequestPayload().description).toBe(
|
|
"Command: [preview truncated or unsafe content omitted]\nSession: agent:main:session-1",
|
|
);
|
|
const omittedEvent = findApprovalEvent(params, {});
|
|
expect(omittedEvent.commandPreviewOmitted).toBe(true);
|
|
});
|
|
|
|
it("marks clipped command previews even when a safe prefix remains", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-clipped-command", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-clipped-command", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-clipped",
|
|
command: `${"a".repeat(5000)} tail`,
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("[preview truncated or unsafe content omitted]");
|
|
const omittedEvent = findApprovalEvent(params, {});
|
|
expect(omittedEvent.commandPreviewOmitted).toBe(true);
|
|
});
|
|
|
|
it("does not trust request-time decisions for two-phase command approvals", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({
|
|
id: "plugin:approval-untrusted",
|
|
status: "accepted",
|
|
decision: "allow-always",
|
|
})
|
|
.mockResolvedValueOnce({ id: "plugin:approval-untrusted", decision: "deny" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-untrusted",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
findApprovalEvent(params, {
|
|
status: "denied",
|
|
approvalId: "plugin:approval-untrusted",
|
|
});
|
|
});
|
|
|
|
it("only treats own null data-property request decisions as no-route", async () => {
|
|
const params = createParams();
|
|
const inheritedDecisionResult = Object.assign(Object.create({ decision: null }), {
|
|
id: "plugin:approval-inherited",
|
|
status: "accepted",
|
|
});
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce(inheritedDecisionResult)
|
|
.mockResolvedValueOnce({ id: "plugin:approval-inherited", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-inherited",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
"plugin.approval.waitDecision",
|
|
]);
|
|
});
|
|
|
|
it("does not invoke request-time decision accessors", async () => {
|
|
const params = createParams();
|
|
const requestResult = {
|
|
id: "plugin:approval-accessor",
|
|
status: "accepted",
|
|
get decision() {
|
|
throw new Error("decision getter must not run");
|
|
},
|
|
};
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce(requestResult)
|
|
.mockResolvedValueOnce({ id: "plugin:approval-accessor", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-accessor",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
});
|
|
|
|
it("does not fail when request-time decision descriptors throw", async () => {
|
|
const params = createParams();
|
|
const requestResult = new Proxy(
|
|
{ id: "plugin:approval-proxy", status: "accepted" },
|
|
{
|
|
getOwnPropertyDescriptor(target, property) {
|
|
if (property === "decision") {
|
|
throw new Error("descriptor trap must not fail approval");
|
|
}
|
|
return Reflect.getOwnPropertyDescriptor(target, property);
|
|
},
|
|
},
|
|
);
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce(requestResult)
|
|
.mockResolvedValueOnce({ id: "plugin:approval-proxy", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "cmd-proxy",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "accept" });
|
|
});
|
|
|
|
it("fails closed when no approval route is available", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool.mockResolvedValueOnce({
|
|
id: "plugin:approval-2",
|
|
decision: null,
|
|
});
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/fileChange/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "patch-1",
|
|
reason: "needs write access",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({ decision: "decline" });
|
|
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
|
|
findApprovalEvent(params, { status: "unavailable", reason: "needs write access" });
|
|
});
|
|
|
|
it("sanitizes reason previews before forwarding approval text and events", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool.mockResolvedValueOnce({
|
|
id: "plugin:approval-sanitized-reason",
|
|
decision: null,
|
|
});
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/fileChange/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "patch-sanitized",
|
|
reason: "needs write access\nfor \u001b[31m/tmp\u001b[0m\tplease",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(gatewayRequestPayload().description).toBe(
|
|
"Reason: needs write access for /tmp please\nSession: agent:main:session-1",
|
|
);
|
|
findApprovalEvent(params, {
|
|
status: "unavailable",
|
|
reason: "needs write access for /tmp please",
|
|
});
|
|
});
|
|
|
|
it("fails closed for unsupported native approval methods without requesting plugin approval", async () => {
|
|
const params = createParams();
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "future/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "future-1",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
decision: "decline",
|
|
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
|
|
});
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
expect(params.onAgentEvent).not.toHaveBeenCalled();
|
|
});
|
|
it("labels permission approvals explicitly with permission detail", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-3", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-3", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "perm-1",
|
|
permissions: {
|
|
network: { allowHosts: ["example.com", "*.internal"] },
|
|
fileSystem: { roots: ["/"], writePaths: ["/home/simone"] },
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
permissions: {
|
|
network: { allowHosts: ["example.com", "*.internal"] },
|
|
fileSystem: { roots: ["/"], writePaths: ["/home/simone"] },
|
|
},
|
|
scope: "turn",
|
|
});
|
|
expect(gatewayCallMethod()).toBe("plugin.approval.request");
|
|
expect(typeof gatewayCallAt(0)[1]).toBe("object");
|
|
const requestPayload = gatewayRequestPayload();
|
|
expect(requestPayload.title).toBe("Codex app-server permission approval");
|
|
expect(requestPayload.toolName).toBe("codex_permission_approval");
|
|
const description = String(requestPayload.description);
|
|
expect(description).toContain("Permissions: network, fileSystem");
|
|
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
|
|
expect(description).toContain("Network allowHosts: example.com, *.internal");
|
|
expect(description).toContain("File system roots: /; writePaths: ~");
|
|
expect(description).toContain(
|
|
"High-risk targets: wildcard hosts, private-network wildcards, filesystem root",
|
|
);
|
|
expect(description).not.toContain("agent:main:session-1");
|
|
});
|
|
|
|
it("keeps permission detail bounded with truncated and compacted target samples", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-4", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-4", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "perm-2",
|
|
permissions: {
|
|
network: {
|
|
allowHosts: [
|
|
"https://secret-token@example.com/private",
|
|
"*.internal",
|
|
"very-long-service-name.example.corp",
|
|
"third.example.com",
|
|
],
|
|
},
|
|
fileSystem: {
|
|
roots: ["/", "/workspace/project", "/Users/simone/Documents"],
|
|
readPaths: ["/Users/simone/.ssh/id_rsa", "/etc/hosts", "/var/log/system.log"],
|
|
writePaths: ["/tmp/output", "/var/log/app", "/home/simone/private"],
|
|
},
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description.length).toBeLessThanOrEqual(700);
|
|
expect(description).toContain("example.com");
|
|
expect(description).not.toContain("secret-token");
|
|
expect(description).not.toContain("simone");
|
|
expect(description).toContain("*.internal");
|
|
expect(description).toContain("/workspace/project");
|
|
expect(description).toContain("High-risk targets:");
|
|
expect(description).toContain("readPaths: ~/.ssh/id_rsa, /etc/hosts");
|
|
});
|
|
|
|
it("describes current protocol network and filesystem permission grants", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-current-permissions", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-current-permissions", decision: "allow-once" });
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "perm-current",
|
|
permissions: {
|
|
network: { enabled: true },
|
|
fileSystem: {
|
|
read: ["/Users/simone/.ssh/id_rsa"],
|
|
write: ["/"],
|
|
entries: [
|
|
{ path: "/workspace/project", access: "read" },
|
|
{ path: "/tmp/output", access: "write" },
|
|
{ path: "/ignored", access: "none" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
permissions: {
|
|
network: { enabled: true },
|
|
fileSystem: {
|
|
read: ["/Users/simone/.ssh/id_rsa"],
|
|
write: ["/"],
|
|
entries: [
|
|
{ path: "/workspace/project", access: "read" },
|
|
{ path: "/tmp/output", access: "write" },
|
|
{ path: "/ignored", access: "none" },
|
|
],
|
|
},
|
|
},
|
|
scope: "turn",
|
|
});
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("Network enabled: true");
|
|
expect(description).toContain("File system read: ~/.ssh/id_rsa; write: /");
|
|
expect(description).toContain("entries: read /workspace/project, write /tmp/output (+1 more)");
|
|
expect(description).toContain("High-risk targets: network access, filesystem root");
|
|
});
|
|
|
|
it("compacts Windows home paths in permission descriptions", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-windows-home", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-windows-home", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "perm-windows-home",
|
|
permissions: {
|
|
fileSystem: {
|
|
roots: ["C:/Users/alice"],
|
|
readPaths: ["C:\\Users\\alice\\.ssh\\id_rsa", "c:/users/bob/project"],
|
|
},
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("File system roots: ~; readPaths: ~/.ssh/id_rsa, ~/project");
|
|
expect(description).not.toContain("High-risk targets");
|
|
});
|
|
|
|
it("strips terminal and invisible controls from permission descriptions", async () => {
|
|
const params = createParams();
|
|
mockCallGatewayTool
|
|
.mockResolvedValueOnce({ id: "plugin:approval-permission-controls", status: "accepted" })
|
|
.mockResolvedValueOnce({ id: "plugin:approval-permission-controls", decision: "allow-once" });
|
|
|
|
await handleCodexAppServerApprovalRequest({
|
|
method: "item/permissions/requestApproval",
|
|
requestParams: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
itemId: "perm-controls",
|
|
permissions: {
|
|
network: { allowHosts: ["exa\u009b31mmple.com", "safe\u202e.example.com"] },
|
|
fileSystem: { roots: ["/tmp/\u001b[31mproject\u001b[0m"] },
|
|
},
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
const description = String(gatewayRequestPayload().description);
|
|
expect(description).toContain("example.com");
|
|
expect(description).toContain("safe .example.com");
|
|
expect(description).toContain("/tmp/project");
|
|
expect(description).not.toContain("\u009b");
|
|
expect(description).not.toContain("\u202e");
|
|
expect(description).not.toContain("\u001b");
|
|
});
|
|
|
|
it("ignores approval requests that are missing explicit thread or turn ids", async () => {
|
|
const params = createParams();
|
|
|
|
const result = await handleCodexAppServerApprovalRequest({
|
|
method: "item/commandExecution/requestApproval",
|
|
requestParams: {
|
|
itemId: "cmd-2",
|
|
command: "pnpm test",
|
|
},
|
|
paramsForRun: params,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(result).toBeUndefined();
|
|
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
|
expect(params.onAgentEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps app-server approval response families separately", () => {
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["accept"] },
|
|
"approved-session",
|
|
),
|
|
).toEqual({
|
|
decision: "accept",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{
|
|
availableDecisions: [
|
|
"accept",
|
|
{
|
|
acceptWithExecpolicyAmendment: {
|
|
execpolicy_amendment: {
|
|
permissions: [{ permission: "allow", command: ["pnpm", "test"] }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"approved-session",
|
|
),
|
|
).toEqual({
|
|
decision: {
|
|
acceptWithExecpolicyAmendment: {
|
|
execpolicy_amendment: {
|
|
permissions: [{ permission: "allow", command: ["pnpm", "test"] }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{
|
|
availableDecisions: [
|
|
{
|
|
applyNetworkPolicyAmendment: {
|
|
network_policy_amendment: {
|
|
domain: "registry.npmjs.org",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"approved-session",
|
|
),
|
|
).toEqual({
|
|
decision: {
|
|
applyNetworkPolicyAmendment: {
|
|
network_policy_amendment: {
|
|
domain: "registry.npmjs.org",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["decline"] },
|
|
"approved-once",
|
|
),
|
|
).toEqual({
|
|
decision: "decline",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["decline"] },
|
|
"approved-session",
|
|
),
|
|
).toEqual({
|
|
decision: "decline",
|
|
});
|
|
expect(
|
|
buildApprovalResponse("item/commandExecution/requestApproval", undefined, "approved-once"),
|
|
).toEqual({
|
|
decision: "accept",
|
|
});
|
|
expect(
|
|
buildApprovalResponse("item/commandExecution/requestApproval", undefined, "approved-session"),
|
|
).toEqual({
|
|
decision: "acceptForSession",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["cancel"] },
|
|
"approved-once",
|
|
),
|
|
).toEqual({
|
|
decision: "cancel",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["accept", "cancel"] },
|
|
"denied",
|
|
),
|
|
).toEqual({
|
|
decision: "cancel",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/commandExecution/requestApproval",
|
|
{ availableDecisions: ["decline"] },
|
|
"cancelled",
|
|
),
|
|
).toEqual({
|
|
decision: "decline",
|
|
});
|
|
expect(buildApprovalResponse("item/fileChange/requestApproval", undefined, "denied")).toEqual({
|
|
decision: "decline",
|
|
});
|
|
expect(
|
|
buildApprovalResponse(
|
|
"item/permissions/requestApproval",
|
|
{
|
|
permissions: {
|
|
network: { allowHosts: ["example.com"] },
|
|
fileSystem: null,
|
|
},
|
|
},
|
|
"approved-once",
|
|
),
|
|
).toEqual({
|
|
permissions: { network: { allowHosts: ["example.com"] } },
|
|
scope: "turn",
|
|
});
|
|
expect(buildApprovalResponse("future/requestApproval", undefined, "approved-once")).toEqual({
|
|
decision: "decline",
|
|
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
|
|
});
|
|
});
|
|
});
|