diff --git a/CHANGELOG.md b/CHANGELOG.md index e631f1b048e..05bbe53a3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867. Thanks @grizdum. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2ab250a1ca9..62b53c5d222 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 2ec16316bc5..20d8d79e791 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -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"); diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index c034fe28374..88fb283ade8 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -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 }, + }, + ); + }); }); diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index 12e20584f3b..00f307657d2 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -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()), 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 { 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" }); }); diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 7c8fd2f4e9c..16568e1e531 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -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; + +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 { + 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; diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 498302b5d2b..beacf184cc4 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -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; diff --git a/extensions/codex/src/app-server/native-hook-relay.test.ts b/extensions/codex/src/app-server/native-hook-relay.test.ts index 19b65990c66..844ebfa36ca 100644 --- a/extensions/codex/src/app-server/native-hook-relay.test.ts +++ b/extensions/codex/src/app-server/native-hook-relay.test.ts @@ -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": { + "//config.toml:pre_tool_use:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "/config.toml:pre_tool_use:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "//config.toml:post_tool_use:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "/config.toml:post_tool_use:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "//config.toml:permission_request:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "/config.toml:permission_request:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "//config.toml:stop:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "/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": { + "//config.toml:permission_request:0:0": { + enabled: true, + trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + "/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", () => { diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts index 5ad233892c2..81b4fa94f5a 100644 --- a/extensions/codex/src/app-server/native-hook-relay.ts +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -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 = { + 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 = [ + "//config.toml", + "/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; +} diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 1992bef5345..968b54b6097 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -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?.["//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 | 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 | 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 | 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"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 427c1df9969..9a2baabfd1c 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -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 { diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 399e603e7d8..a2a47f84103 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -83,6 +83,10 @@ type HookOutcome = | { blocked: false; params: unknown }; type PluginApprovalRequest = NonNullable; +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 = diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index da8bd6c2a9b..e41b013b689 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -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 {