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:
Josh Avant
2026-05-16 03:02:28 -05:00
committed by GitHub
parent 23f73b3ecf
commit e57b137aef
13 changed files with 607 additions and 26 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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");

View File

@@ -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 },
},
);
});
});

View File

@@ -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" });
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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 {