mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:54:47 +00:00
fix(codex): enforce native tool policy (#82496)
* fix(codex): enforce native tool policy * docs: add changelog for codex native policy fix * fix(codex): satisfy native hook relay lint
This commit is contained in:
@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: drain queued outbound deliveries after polling reconnect confirms fresh `getUpdates` activity, so stale-socket and network recovery do not leave failed replies stranded. Fixes #50040. Refs #82175. Thanks @dmitriiforpost-commits and @shellyrocklobster.
|
||||
- Gateway/model auth: abort active provider runs when saved auth is removed through the Gateway control plane, refresh live runtime auth snapshots, and surface `stopReason: "auth-revoked"` to clients. Fixes #81987. (#82346) Thanks @joshavant.
|
||||
- Codex app-server: keep the raw tool-output idle watchdog armed after `custom_tool_call_output` notifications, so post-tool stream silence fails fast instead of waiting for the terminal idle timeout. Fixes #82274. (#82378) Thanks @joshavant.
|
||||
- Codex app-server: enforce OpenClaw `before_tool_call` policy for Codex-native app-server shell and approval paths, preventing native tool execution from bypassing plugin policy. Fixes #82372. (#82496) Thanks @joshavant.
|
||||
- Telegram: mark isolated polling ingress unhealthy when a spooled inbound backlog stalls while Bot API polling still succeeds, so gateway/channel health no longer stays green after Telegram DM processing wedges. Fixes #82175. Thanks @shellyrocklobster.
|
||||
- Telegram: drop expired approval callbacks from isolated polling after approval id expiry so stale inline-button updates do not retry forever across restarts. Fixes #82347. (#82455) Thanks @joshavant.
|
||||
- Agents: strip Gemini/Gemma `<final>` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867. Thanks @grizdum.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ae6b95dffe88496aadee03e49b6b6db2db74d4bbd9b984be94a39d81df449f93 plugin-sdk-api-baseline.json
|
||||
08da4f6d26afff58fc1accb2f1b12c2d0ef740a0abf60cbdef43a32f422a4382 plugin-sdk-api-baseline.jsonl
|
||||
56a29bf137ba67d2c4a428c9d45bf207bc61278f83a28fea972c583f698be62e plugin-sdk-api-baseline.json
|
||||
0f1c320de15ec315e95acfc4b3acb3333c8b7f86cd14df03070bc540ab4a598e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -38,7 +38,10 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
},
|
||||
runAttempt: async (params) => {
|
||||
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
|
||||
return runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig });
|
||||
return runCodexAppServerAttempt(params, {
|
||||
pluginConfig: options?.pluginConfig,
|
||||
nativeHookRelay: { enabled: true },
|
||||
});
|
||||
},
|
||||
runSideQuestion: async (params) => {
|
||||
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
|
||||
|
||||
@@ -4,6 +4,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/app-server/run-attempt.js", () => ({
|
||||
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
|
||||
}));
|
||||
|
||||
function mockCall(mock: { mock: { calls: unknown[][] } }, index = 0) {
|
||||
return mock.mock.calls.at(index);
|
||||
}
|
||||
@@ -123,4 +129,20 @@ describe("codex plugin", () => {
|
||||
});
|
||||
expect(unsupported.supported).toBe(false);
|
||||
});
|
||||
|
||||
it("enables the native hook relay for public Codex app-server attempts", async () => {
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const result = { success: true };
|
||||
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
|
||||
|
||||
await expect(harness.runAttempt({ prompt: "hello" } as never)).resolves.toBe(result);
|
||||
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "hello" },
|
||||
{
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
callGatewayTool,
|
||||
runBeforeToolCallHook,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -8,9 +9,14 @@ import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./ap
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
|
||||
callGatewayTool: vi.fn(),
|
||||
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
|
||||
blocked: false,
|
||||
params,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -41,7 +47,13 @@ function gatewayCallMethod(callIndex = 0) {
|
||||
|
||||
function findApprovalEvent(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
fields: { status?: string; approvalId?: string; command?: string; reason?: string },
|
||||
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;
|
||||
@@ -58,7 +70,8 @@ function findApprovalEvent(
|
||||
(!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.reason || data.reason === fields.reason) &&
|
||||
(!fields.message || data.message === fields.message)
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
@@ -81,6 +94,11 @@ function createParams(): EmbeddedRunAttemptParams {
|
||||
describe("Codex app-server approval bridge", () => {
|
||||
beforeEach(() => {
|
||||
mockCallGatewayTool.mockReset();
|
||||
mockRunBeforeToolCallHook.mockReset();
|
||||
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
|
||||
blocked: false,
|
||||
params,
|
||||
}));
|
||||
});
|
||||
|
||||
it("routes command approvals through plugin approvals and accepts allowed commands", async () => {
|
||||
@@ -116,10 +134,123 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(requestPayload.turnSourceChannel).toBe("telegram");
|
||||
expect(requestPayload.turnSourceTo).toBe("chat-1");
|
||||
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
|
||||
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith({
|
||||
toolName: "bash",
|
||||
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: "report",
|
||||
signal: undefined,
|
||||
ctx: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
channelId: "telegram",
|
||||
},
|
||||
});
|
||||
findApprovalEvent(params, { status: "pending", approvalId: "plugin:approval-1" });
|
||||
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" });
|
||||
});
|
||||
|
||||
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("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("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
|
||||
@@ -143,6 +274,12 @@ describe("Codex app-server approval bridge", () => {
|
||||
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: "bash",
|
||||
params: {
|
||||
command: "bash -lc 'pnpm test extensions/codex'",
|
||||
},
|
||||
});
|
||||
findApprovalEvent(params, { command: "pnpm test extensions/codex" });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AgentApprovalEventData,
|
||||
formatApprovalDisplayPath,
|
||||
type EmbeddedRunAttemptParams,
|
||||
runBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { formatCodexDisplayText } from "../command-formatters.js";
|
||||
import {
|
||||
@@ -69,6 +70,26 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
});
|
||||
|
||||
try {
|
||||
const policyOutcome = await runOpenClawToolPolicyForApprovalRequest({
|
||||
method: params.method,
|
||||
requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
context,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (policyOutcome?.blocked) {
|
||||
emitApprovalEvent(params.paramsForRun, {
|
||||
phase: "resolved",
|
||||
kind: context.kind,
|
||||
status: "denied",
|
||||
title: context.title,
|
||||
...context.eventDetails,
|
||||
...approvalEventScope(params.method, "denied"),
|
||||
message: policyOutcome.reason,
|
||||
});
|
||||
return buildApprovalResponse(params.method, context.requestParams, "denied");
|
||||
}
|
||||
|
||||
const requestResult = await requestPluginApproval({
|
||||
paramsForRun: params.paramsForRun,
|
||||
title: context.title,
|
||||
@@ -267,6 +288,117 @@ function buildApprovalContext(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type ApprovalContext = ReturnType<typeof buildApprovalContext>;
|
||||
|
||||
async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
context: ApprovalContext;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ blocked: true; reason: string } | undefined> {
|
||||
const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams);
|
||||
if (!policyRequest) {
|
||||
return undefined;
|
||||
}
|
||||
const cwd = readString(params.requestParams, "cwd") ?? params.paramsForRun.workspaceDir;
|
||||
const outcome = await runBeforeToolCallHook({
|
||||
toolName: policyRequest.toolName,
|
||||
params: policyRequest.params,
|
||||
...(params.context.itemId ? { toolCallId: params.context.itemId } : {}),
|
||||
approvalMode: "report",
|
||||
signal: params.signal,
|
||||
ctx: {
|
||||
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
|
||||
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
|
||||
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
|
||||
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
|
||||
...(params.paramsForRun.messageChannel || params.paramsForRun.messageProvider
|
||||
? { channelId: params.paramsForRun.messageChannel ?? params.paramsForRun.messageProvider }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (outcome.blocked) {
|
||||
return { blocked: true, reason: outcome.reason };
|
||||
}
|
||||
if ("params" in outcome && toolPolicyParamsWereRewritten(policyRequest.params, outcome.params)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason:
|
||||
"OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildOpenClawToolPolicyRequest(
|
||||
method: string,
|
||||
requestParams: JsonObject | undefined,
|
||||
): { toolName: string; params: JsonObject } | undefined {
|
||||
if (method === "item/commandExecution/requestApproval") {
|
||||
const command = readPolicyCommand(requestParams);
|
||||
return {
|
||||
toolName: "bash",
|
||||
params: {
|
||||
...(command ? { command } : {}),
|
||||
...(readString(requestParams, "cwd") ? { cwd: readString(requestParams, "cwd") } : {}),
|
||||
approval: requestParams ?? {},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "item/fileChange/requestApproval") {
|
||||
return { toolName: "apply_patch", params: requestParams ?? {} };
|
||||
}
|
||||
if (method === "item/permissions/requestApproval") {
|
||||
return { toolName: "codex_permission_approval", params: requestParams ?? {} };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toolPolicyParamsWereRewritten(original: JsonObject, candidate: unknown): boolean {
|
||||
if (candidate === original) {
|
||||
return false;
|
||||
}
|
||||
const originalText = stableJsonText(original);
|
||||
const candidateText = stableJsonText(candidate);
|
||||
return !candidateText || candidateText !== originalText;
|
||||
}
|
||||
|
||||
function stableJsonText(value: unknown): string | undefined {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((item) => stableJsonText(item));
|
||||
return items.every((item): item is string => item !== undefined)
|
||||
? `[${items.join(",")}]`
|
||||
: undefined;
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
const entries = Object.entries(value)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => {
|
||||
const text = stableJsonText(item);
|
||||
return text === undefined ? undefined : `${JSON.stringify(key)}:${text}`;
|
||||
});
|
||||
return entries.every((entry): entry is string => entry !== undefined)
|
||||
? `{${entries.join(",")}}`
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function commandApprovalDecision(
|
||||
requestParams: JsonObject | undefined,
|
||||
outcome: AppServerApprovalOutcome,
|
||||
@@ -758,19 +890,36 @@ function readDisplayCommandPreview(
|
||||
return readCommandPreview(record);
|
||||
}
|
||||
|
||||
function readPolicyCommand(record: JsonObject | undefined): string | undefined {
|
||||
const command = record?.command;
|
||||
if (typeof command === "string") {
|
||||
return command;
|
||||
}
|
||||
if (Array.isArray(command) && command.every((part): part is string => typeof part === "string")) {
|
||||
return command.join(" ");
|
||||
}
|
||||
const actionCommands = readCommandActions(record);
|
||||
if (actionCommands.length > 0) {
|
||||
return actionCommands.join(" && ");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCommandActions(record: JsonObject | undefined): string[] {
|
||||
const actions = record?.commandActions;
|
||||
if (!Array.isArray(actions)) {
|
||||
return [];
|
||||
}
|
||||
return actions
|
||||
.map((action) => (isJsonObject(action) ? readString(action, "command") : undefined))
|
||||
.filter((command): command is string => Boolean(command));
|
||||
}
|
||||
|
||||
function readCommandActionsPreview(
|
||||
record: JsonObject | undefined,
|
||||
): ApprovalPreviewSource | undefined {
|
||||
const actions = record?.commandActions;
|
||||
if (!Array.isArray(actions)) {
|
||||
return undefined;
|
||||
}
|
||||
let source: ApprovalPreviewSource | undefined;
|
||||
for (const action of actions) {
|
||||
const command = isJsonObject(action) ? readString(action, "command") : undefined;
|
||||
if (!command) {
|
||||
continue;
|
||||
}
|
||||
for (const command of readCommandActions(record)) {
|
||||
source = appendPreviewPart(source, command, " && ");
|
||||
if (source.clipped) {
|
||||
break;
|
||||
|
||||
@@ -389,6 +389,24 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexAppServerApprovalPolicyAllowedByRequirements(
|
||||
policy: CodexAppServerApprovalPolicy,
|
||||
params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
} = {},
|
||||
): boolean {
|
||||
const content = readCodexRequirementsToml(params);
|
||||
if (content === undefined) {
|
||||
return true;
|
||||
}
|
||||
const allowedApprovalPolicies = parseAllowedApprovalPoliciesFromCodexRequirements(content);
|
||||
return allowedApprovalPolicies === undefined || allowedApprovalPolicies.has(policy);
|
||||
}
|
||||
|
||||
export function resolveCodexComputerUseConfig(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
|
||||
@@ -16,7 +16,6 @@ describe("Codex native hook relay config", () => {
|
||||
"features.hooks": true,
|
||||
"hooks.PreToolUse": [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
@@ -31,7 +30,6 @@ describe("Codex native hook relay config", () => {
|
||||
],
|
||||
"hooks.PostToolUse": [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
@@ -46,7 +44,6 @@ describe("Codex native hook relay config", () => {
|
||||
],
|
||||
"hooks.PermissionRequest": [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
@@ -61,7 +58,6 @@ describe("Codex native hook relay config", () => {
|
||||
],
|
||||
"hooks.Stop": [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
@@ -74,8 +70,43 @@ describe("Codex native hook relay config", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
"hooks.state": {
|
||||
"/<session-flags>/config.toml:pre_tool_use:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"<session-flags>/config.toml:pre_tool_use:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"/<session-flags>/config.toml:post_tool_use:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"<session-flags>/config.toml:post_tool_use:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"/<session-flags>/config.toml:permission_request:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"<session-flags>/config.toml:permission_request:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"/<session-flags>/config.toml:stop:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"<session-flags>/config.toml:stop:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(config)).not.toContain("timeoutSec");
|
||||
expect(JSON.stringify(config)).not.toContain('"matcher":null');
|
||||
expect(config).not.toHaveProperty("hooks.SessionStart");
|
||||
expect(config).not.toHaveProperty("hooks.UserPromptSubmit");
|
||||
});
|
||||
@@ -90,7 +121,6 @@ describe("Codex native hook relay config", () => {
|
||||
"features.hooks": true,
|
||||
"hooks.PermissionRequest": [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
@@ -103,17 +133,31 @@ describe("Codex native hook relay config", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
"hooks.state": {
|
||||
"/<session-flags>/config.toml:permission_request:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
"<session-flags>/config.toml:permission_request:0:0": {
|
||||
enabled: true,
|
||||
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves matchers open so Codex MCP tool names reach the relay", () => {
|
||||
it("omits matchers so Codex MCP tool names reach the relay with a stable trust hash", () => {
|
||||
const config = buildCodexNativeHookRelayConfig({
|
||||
relay: createRelay(),
|
||||
events: ["pre_tool_use", "post_tool_use"],
|
||||
});
|
||||
|
||||
expect((config["hooks.PreToolUse"] as Array<{ matcher: unknown }>)[0]?.matcher).toBeNull();
|
||||
expect((config["hooks.PostToolUse"] as Array<{ matcher: unknown }>)[0]?.matcher).toBeNull();
|
||||
expect((config["hooks.PreToolUse"] as Array<{ matcher?: unknown }>)[0]).not.toHaveProperty(
|
||||
"matcher",
|
||||
);
|
||||
expect((config["hooks.PostToolUse"] as Array<{ matcher?: unknown }>)[0]).not.toHaveProperty(
|
||||
"matcher",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds deterministic clearing config when the relay is disabled", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
NativeHookRelayEvent,
|
||||
NativeHookRelayRegistrationHandle,
|
||||
@@ -20,6 +21,18 @@ const CODEX_HOOK_EVENT_BY_NATIVE_EVENT: Record<NativeHookRelayEvent, CodexHookEv
|
||||
before_agent_finalize: "Stop",
|
||||
};
|
||||
|
||||
const CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT: Record<NativeHookRelayEvent, string> = {
|
||||
pre_tool_use: "pre_tool_use",
|
||||
post_tool_use: "post_tool_use",
|
||||
permission_request: "permission_request",
|
||||
before_agent_finalize: "stop",
|
||||
};
|
||||
|
||||
const CODEX_SESSION_FLAGS_HOOK_SOURCE_PATHS = [
|
||||
"/<session-flags>/config.toml",
|
||||
"<session-flags>/config.toml",
|
||||
] as const;
|
||||
|
||||
export function buildCodexNativeHookRelayConfig(params: {
|
||||
relay: NativeHookRelayRegistrationHandle;
|
||||
events?: readonly NativeHookRelayEvent[];
|
||||
@@ -29,23 +42,39 @@ export function buildCodexNativeHookRelayConfig(params: {
|
||||
const config: JsonObject = {
|
||||
"features.hooks": true,
|
||||
};
|
||||
const hookState: JsonObject = {};
|
||||
for (const event of events) {
|
||||
const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event];
|
||||
const command = params.relay.commandForEvent(event);
|
||||
const timeout = normalizeHookTimeoutSec(params.hookTimeoutSec);
|
||||
config[`hooks.${codexEvent}`] = [
|
||||
{
|
||||
matcher: null,
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: params.relay.commandForEvent(event),
|
||||
timeout: normalizeHookTimeoutSec(params.hookTimeoutSec),
|
||||
command,
|
||||
timeout,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies JsonValue;
|
||||
const state = {
|
||||
enabled: true,
|
||||
trusted_hash: codexCommandHookTrustedHash({
|
||||
event,
|
||||
command,
|
||||
timeout,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
}),
|
||||
};
|
||||
for (const sourcePath of CODEX_SESSION_FLAGS_HOOK_SOURCE_PATHS) {
|
||||
hookState[`${sourcePath}:${CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT[event]}:0:0`] =
|
||||
state satisfies JsonValue;
|
||||
}
|
||||
}
|
||||
config["hooks.state"] = hookState;
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -62,3 +91,44 @@ export function buildCodexNativeHookRelayDisabledConfig(): JsonObject {
|
||||
function normalizeHookTimeoutSec(value: number | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5;
|
||||
}
|
||||
|
||||
function codexCommandHookTrustedHash(params: {
|
||||
event: NativeHookRelayEvent;
|
||||
command: string;
|
||||
timeout: number;
|
||||
statusMessage: string;
|
||||
}): string {
|
||||
// Keep the match-all matcher omitted rather than null. Codex app-server
|
||||
// converts JSON null to an empty TOML string before hashing, which changes the
|
||||
// trust identity even though both forms match all tools.
|
||||
const identity = {
|
||||
event_name: CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT[params.event],
|
||||
hooks: [
|
||||
{
|
||||
async: false,
|
||||
command: params.command,
|
||||
statusMessage: params.statusMessage,
|
||||
timeout: params.timeout,
|
||||
type: "command",
|
||||
},
|
||||
],
|
||||
};
|
||||
const hash = createHash("sha256")
|
||||
.update(JSON.stringify(sortJsonValue(identity)))
|
||||
.digest("hex");
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
|
||||
function sortJsonValue(value: JsonValue): JsonValue {
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortJsonValue);
|
||||
}
|
||||
const sorted: JsonObject = {};
|
||||
for (const key of Object.keys(value).toSorted()) {
|
||||
sorted[key] = sortJsonValue(value[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
@@ -3079,10 +3079,91 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(preToolUseCommand?.type).toBe("command");
|
||||
expect(preToolUseCommand?.timeout).toBe(9);
|
||||
expect(preToolUseCommand?.command).toContain("--event pre_tool_use --timeout 4321");
|
||||
const hookState = startConfig?.["hooks.state"] as Record<
|
||||
string,
|
||||
{ enabled?: unknown; trusted_hash?: unknown }
|
||||
>;
|
||||
const preToolUseState = hookState?.["/<session-flags>/config.toml:pre_tool_use:0:0"];
|
||||
expect(preToolUseState?.enabled).toBe(true);
|
||||
expect(preToolUseState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("promotes implicit Codex yolo approval policy when OpenClaw tool policy exists", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as Record<string, unknown> | undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("untrusted");
|
||||
expect(startParams?.sandbox).toBe("danger-full-access");
|
||||
});
|
||||
|
||||
it("keeps implicit Codex yolo approval policy when untrusted approvals are disallowed", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null });
|
||||
|
||||
const resolved = __testing.resolveCodexAppServerForOpenClawToolPolicy({
|
||||
appServer,
|
||||
pluginConfig: readCodexPluginConfig({}),
|
||||
env: {},
|
||||
shouldPromote: true,
|
||||
canUseUntrustedApprovalPolicy: false,
|
||||
});
|
||||
|
||||
expect(resolved.approvalPolicy).toBe("never");
|
||||
});
|
||||
|
||||
it("keeps explicit Codex yolo mode unpromoted when OpenClaw tool policy exists", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as Record<string, unknown> | undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("never");
|
||||
expect(startParams?.sandbox).toBe("danger-full-access");
|
||||
});
|
||||
|
||||
it("ignores invalid Codex app-server env overrides when promoting tool policy approval", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
);
|
||||
vi.stubEnv("OPENCLAW_CODEX_APP_SERVER_MODE", " ");
|
||||
vi.stubEnv("OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY", "always");
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as Record<string, unknown> | undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("untrusted");
|
||||
});
|
||||
|
||||
it("keeps the native hook relay default floor for short Codex turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
emitAgentEvent as emitGlobalAgentEvent,
|
||||
finalizeHarnessContextEngineTurn,
|
||||
formatErrorMessage,
|
||||
hasBeforeToolCallPolicy,
|
||||
isActiveHarnessContextEngine,
|
||||
isSubagentSessionKey,
|
||||
loadCodexBundleMcpThreadConfig,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "./client.js";
|
||||
import { ensureCodexComputerUse } from "./computer-use.js";
|
||||
import {
|
||||
isCodexAppServerApprovalPolicyAllowedByRequirements,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexPluginsPolicy,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
@@ -429,6 +431,45 @@ function restrictCodexAppServerSandboxForOpenClawSandbox(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
shouldPromote: boolean;
|
||||
canUseUntrustedApprovalPolicy: boolean;
|
||||
}): CodexAppServerRuntimeOptions {
|
||||
if (
|
||||
!params.shouldPromote ||
|
||||
!params.canUseUntrustedApprovalPolicy ||
|
||||
params.appServer.approvalPolicy !== "never"
|
||||
) {
|
||||
return params.appServer;
|
||||
}
|
||||
const explicitMode =
|
||||
params.pluginConfig.appServer?.mode !== undefined ||
|
||||
isCodexAppServerPolicyMode(params.env.OPENCLAW_CODEX_APP_SERVER_MODE);
|
||||
const explicitApprovalPolicy =
|
||||
params.pluginConfig.appServer?.approvalPolicy !== undefined ||
|
||||
isCodexAppServerApprovalPolicy(params.env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY);
|
||||
if (explicitMode || explicitApprovalPolicy) {
|
||||
return params.appServer;
|
||||
}
|
||||
return {
|
||||
...params.appServer,
|
||||
approvalPolicy: "untrusted",
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexAppServerPolicyMode(value: unknown): boolean {
|
||||
return value === "guardian" || value === "yolo";
|
||||
}
|
||||
|
||||
function isCodexAppServerApprovalPolicy(value: unknown): boolean {
|
||||
return (
|
||||
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: {
|
||||
@@ -466,7 +507,15 @@ export async function runCodexAppServerAttempt(
|
||||
: sandbox.workspaceDir
|
||||
: resolvedWorkspace;
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
const appServer = restrictCodexAppServerSandboxForOpenClawSandbox(configuredAppServer, sandbox);
|
||||
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
|
||||
appServer: restrictCodexAppServerSandboxForOpenClawSandbox(configuredAppServer, sandbox),
|
||||
pluginConfig,
|
||||
env: process.env,
|
||||
shouldPromote: hasBeforeToolCallPolicy(),
|
||||
canUseUntrustedApprovalPolicy:
|
||||
configuredAppServer.start.transport !== "stdio" ||
|
||||
isCodexAppServerApprovalPolicyAllowedByRequirements("untrusted"),
|
||||
});
|
||||
let pluginAppServer: CodexAppServerRuntimeOptions = appServer;
|
||||
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
|
||||
configuredEvents: options.nativeHookRelay?.events,
|
||||
@@ -3544,6 +3593,7 @@ export const __testing = {
|
||||
remapCodexContextFilePath,
|
||||
resolveDynamicToolCallTimeoutMs,
|
||||
restrictCodexAppServerSandboxForOpenClawSandbox,
|
||||
resolveCodexAppServerForOpenClawToolPolicy,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldForceMessageTool,
|
||||
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {
|
||||
|
||||
@@ -83,6 +83,10 @@ type HookOutcome =
|
||||
| { blocked: false; params: unknown };
|
||||
type PluginApprovalRequest = NonNullable<PluginHookBeforeToolCallResult["requireApproval"]>;
|
||||
|
||||
export function hasBeforeToolCallPolicy(): boolean {
|
||||
return getGlobalHookRunner()?.hasHooks("before_tool_call") === true || hasTrustedToolPolicies();
|
||||
}
|
||||
|
||||
const log = createSubsystemLogger("agents/tools");
|
||||
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
|
||||
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =
|
||||
|
||||
@@ -165,7 +165,9 @@ export {
|
||||
export { appendSessionTranscriptMessage } from "../config/sessions/transcript-append.js";
|
||||
export { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
export {
|
||||
hasBeforeToolCallPolicy,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
runBeforeToolCallHook,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "../agents/pi-tools.before-tool-call.js";
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user