Skip to content

Commit f011d6b

Browse files
Fix repeated Codex native approval prompts after allow-always (#78234)
* fix: reuse codex native approvals * fix: scope native approval reuse by session * fix: let codex guardian own native permission approvals * fix: refresh plugin approval protocol models --------- Co-authored-by: pashpashpash <nik@vault77.ai>
1 parent 97b07ea commit f011d6b

18 files changed

Lines changed: 631 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
1111
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
1212
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
13+
- Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.
1314
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
1415
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
1516
- Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface.

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52275227
public let severity: String?
52285228
public let toolname: String?
52295229
public let toolcallid: String?
5230+
public let alloweddecisions: [String]?
52305231
public let agentid: String?
52315232
public let sessionkey: String?
52325233
public let turnsourcechannel: String?
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52435244
severity: String?,
52445245
toolname: String?,
52455246
toolcallid: String?,
5247+
alloweddecisions: [String]?,
52465248
agentid: String?,
52475249
sessionkey: String?,
52485250
turnsourcechannel: String?,
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52585260
self.severity = severity
52595261
self.toolname = toolname
52605262
self.toolcallid = toolcallid
5263+
self.alloweddecisions = alloweddecisions
52615264
self.agentid = agentid
52625265
self.sessionkey = sessionkey
52635266
self.turnsourcechannel = turnsourcechannel
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52755278
case severity
52765279
case toolname = "toolName"
52775280
case toolcallid = "toolCallId"
5281+
case alloweddecisions = "allowedDecisions"
52785282
case agentid = "agentId"
52795283
case sessionkey = "sessionKey"
52805284
case turnsourcechannel = "turnSourceChannel"

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52275227
public let severity: String?
52285228
public let toolname: String?
52295229
public let toolcallid: String?
5230+
public let alloweddecisions: [String]?
52305231
public let agentid: String?
52315232
public let sessionkey: String?
52325233
public let turnsourcechannel: String?
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52435244
severity: String?,
52445245
toolname: String?,
52455246
toolcallid: String?,
5247+
alloweddecisions: [String]?,
52465248
agentid: String?,
52475249
sessionkey: String?,
52485250
turnsourcechannel: String?,
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52585260
self.severity = severity
52595261
self.toolname = toolname
52605262
self.toolcallid = toolcallid
5263+
self.alloweddecisions = alloweddecisions
52615264
self.agentid = agentid
52625265
self.sessionkey = sessionkey
52635266
self.turnsourcechannel = turnsourcechannel
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
52755278
case severity
52765279
case toolname = "toolName"
52775280
case toolcallid = "toolCallId"
5281+
case alloweddecisions = "allowedDecisions"
52785282
case agentid = "agentId"
52795283
case sessionkey = "sessionKey"
52805284
case turnsourcechannel = "turnSourceChannel"

docs/plugins/codex-harness.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -944,9 +944,14 @@ The Codex harness has three hook layers:
944944
OpenClaw does not use project or global Codex `hooks.json` files to route
945945
OpenClaw plugin behavior. For the supported native tool and permission bridge,
946946
OpenClaw injects per-thread Codex config for `PreToolUse`, `PostToolUse`,
947-
`PermissionRequest`, and `Stop`. Other Codex hooks such as `SessionStart` and
948-
`UserPromptSubmit` remain Codex-level controls; they are not exposed as
949-
OpenClaw plugin hooks in the v1 contract.
947+
`PermissionRequest`, and `Stop`. When Codex app-server approvals are enabled
948+
(`approvalPolicy` is not `"never"`), the default injected native hook config
949+
omits `PermissionRequest` so Codex's app-server reviewer and OpenClaw's approval
950+
bridge handle real escalations after review. Operators can still explicitly add
951+
`permission_request` to `nativeHookRelay.events` when they need the compatibility
952+
relay. Other Codex hooks such as `SessionStart` and `UserPromptSubmit` remain
953+
Codex-level controls; they are not exposed as OpenClaw plugin hooks in the v1
954+
contract.
950955

951956
For OpenClaw dynamic tools, OpenClaw executes the tool after Codex asks for the
952957
call, so OpenClaw fires the plugin and middleware behavior it owns in the
@@ -973,19 +978,19 @@ around that boundary.
973978

974979
Supported in Codex runtime v1:
975980

976-
| Surface | Support | Why |
977-
| --------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
978-
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
979-
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
980-
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
981-
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
982-
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
983-
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
984-
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
985-
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
986-
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
987-
| Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. If OpenClaw returns no decision, Codex continues through its normal guardian or user approval path. |
988-
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
981+
| Surface | Support | Why |
982+
| --------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
983+
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
984+
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
985+
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
986+
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
987+
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
988+
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
989+
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
990+
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
991+
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
992+
| Native permission policy | Supported through Codex app-server approvals and the compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. |
993+
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
989994

990995
Not supported in Codex runtime v1:
991996

@@ -1016,6 +1021,14 @@ it.
10161021
For `PermissionRequest`, OpenClaw only returns explicit allow or deny decisions
10171022
when policy decides. A no-decision result is not an allow. Codex treats it as no
10181023
hook decision and falls through to its own guardian or user approval path.
1024+
Codex app-server approval modes omit this native hook by default; this paragraph
1025+
applies when `permission_request` is explicitly included in
1026+
`nativeHookRelay.events` or a compatibility runtime installs it.
1027+
When an operator chooses `allow-always` for a Codex native permission request,
1028+
OpenClaw remembers that exact provider/session/tool input/cwd fingerprint for a
1029+
bounded session window. The remembered decision is intentionally exact-match
1030+
only: a changed command, arguments, tool payload, or cwd creates a fresh
1031+
approval.
10191032

10201033
Codex MCP tool approval elicitations are routed through OpenClaw's plugin
10211034
approval flow when Codex marks `_meta.codex_approval_kind` as

docs/tools/exec-approvals-advanced.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ The config shape is identical to `approvals.exec`: `enabled`, `mode`, `agentFilt
233233
Channels that support shared interactive replies render the same approval buttons for both exec and
234234
plugin approvals. Channels without shared interactive UI fall back to plain text with `/approve`
235235
instructions.
236+
Plugin approval requests may restrict the available decisions. Approval surfaces use the request's
237+
declared decision set, and the Gateway rejects attempts to submit a decision that was not offered.
236238

237239
### Same-chat approvals on any channel
238240

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -364,13 +364,28 @@ type AppServerRequestHandler = (request: {
364364
}) => Promise<unknown>;
365365

366366
function extractRelayIdFromThreadRequest(params: unknown): string {
367-
const command = (
368-
params as {
369-
config?: {
370-
"hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>;
371-
};
367+
const config = (params as { config?: Record<string, unknown> }).config;
368+
let command: string | undefined;
369+
for (const key of [
370+
"hooks.PreToolUse",
371+
"hooks.PostToolUse",
372+
"hooks.PermissionRequest",
373+
"hooks.Stop",
374+
]) {
375+
const entries = config?.[key];
376+
if (!Array.isArray(entries)) {
377+
continue;
372378
}
373-
).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command;
379+
for (const entry of entries as Array<{ hooks?: Array<{ command?: string }> }>) {
380+
command = entry.hooks?.find((hook) => typeof hook.command === "string")?.command;
381+
if (command) {
382+
break;
383+
}
384+
}
385+
if (command) {
386+
break;
387+
}
388+
}
374389
const match = command?.match(/--relay-id ([^ ]+)/);
375390
if (!match?.[1]) {
376391
throw new Error(`relay id missing from command: ${command}`);
@@ -1153,6 +1168,85 @@ describe("runCodexAppServerAttempt", () => {
11531168
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
11541169
});
11551170

1171+
it("lets Codex app-server approval modes own native permission requests by default", async () => {
1172+
const sessionFile = path.join(tempDir, "session.jsonl");
1173+
const workspaceDir = path.join(tempDir, "workspace");
1174+
const harness = createStartedThreadHarness();
1175+
1176+
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
1177+
pluginConfig: {
1178+
appServer: {
1179+
mode: "guardian",
1180+
},
1181+
},
1182+
});
1183+
await harness.waitForMethod("turn/start");
1184+
1185+
const startRequest = harness.requests.find((request) => request.method === "thread/start");
1186+
expect(startRequest?.params).toEqual(
1187+
expect.objectContaining({
1188+
config: expect.objectContaining({
1189+
"features.codex_hooks": true,
1190+
"hooks.PreToolUse": expect.any(Array),
1191+
"hooks.PostToolUse": expect.any(Array),
1192+
"hooks.Stop": expect.any(Array),
1193+
}),
1194+
}),
1195+
);
1196+
expect(startRequest?.params).toEqual(
1197+
expect.objectContaining({
1198+
config: expect.not.objectContaining({
1199+
"hooks.PermissionRequest": expect.anything(),
1200+
}),
1201+
}),
1202+
);
1203+
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
1204+
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
1205+
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
1206+
});
1207+
1208+
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
1209+
await run;
1210+
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
1211+
});
1212+
1213+
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
1214+
const sessionFile = path.join(tempDir, "session.jsonl");
1215+
const workspaceDir = path.join(tempDir, "workspace");
1216+
const harness = createStartedThreadHarness();
1217+
1218+
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
1219+
pluginConfig: {
1220+
appServer: {
1221+
mode: "guardian",
1222+
},
1223+
},
1224+
nativeHookRelay: {
1225+
enabled: true,
1226+
events: ["permission_request"],
1227+
},
1228+
});
1229+
await harness.waitForMethod("turn/start");
1230+
1231+
const startRequest = harness.requests.find((request) => request.method === "thread/start");
1232+
expect(startRequest?.params).toEqual(
1233+
expect.objectContaining({
1234+
config: expect.objectContaining({
1235+
"features.codex_hooks": true,
1236+
"hooks.PermissionRequest": expect.any(Array),
1237+
}),
1238+
}),
1239+
);
1240+
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
1241+
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
1242+
allowedEvents: ["permission_request"],
1243+
});
1244+
1245+
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
1246+
await run;
1247+
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
1248+
});
1249+
11561250
it("reuses the Codex native hook relay id across runs for the same session", async () => {
11571251
const sessionFile = path.join(tempDir, "session.jsonl");
11581252
const workspaceDir = path.join(tempDir, "workspace");

0 commit comments

Comments
 (0)