Skip to content

Commit c5d34c8

Browse files
authored
feat(codex): add always plugin approval mode (#97123)
* feat(codex): add always plugin approval mode * fix(codex): normalize plugin approval decisions * fix(codex): fail closed on layered approval overrides
1 parent fbfadbd commit c5d34c8

19 files changed

Lines changed: 849 additions & 41 deletions

docs/gateway/configuration-reference.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
316316
plugin/app support for the Codex harness. Default: `false`.
317317
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
318318
default destructive-action policy for migrated plugin app elicitations.
319+
Use `true` to accept safe Codex approval schemas without prompting, `false`
320+
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
321+
plugin approvals, or `"always"` to ask for every plugin write/destructive
322+
action without durable approval. The `"always"` mode clears durable Codex
323+
per-tool approval overrides for the affected app before starting the thread.
319324
Default: `true`.
320325
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
321326
migrated plugin entry when global `codexPlugins.enabled` is also true.
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
326331
Codex plugin identity from migration, for example `"google-calendar"`.
327332
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
328333
per-plugin destructive-action override. When omitted, the global
329-
`allow_destructive_actions` value is used.
334+
`allow_destructive_actions` value is used. The per-plugin value accepts the
335+
same `true`, `false`, `"auto"`, or `"always"` policies.
330336

331337
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
332338
entries written by migration are the durable install and repair eligibility set.

docs/plugins/codex-native-plugins.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,11 @@ enabled.
200200

201201
OpenClaw sets app-level `destructive_enabled` from the effective global or
202202
per-plugin `allow_destructive_actions` policy and lets Codex enforce
203-
destructive tool metadata from its native app tool annotations. `true` and
204-
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
205-
`_default` app config is disabled with `open_world_enabled: false`. Enabled
206-
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
207-
expose a separate plugin open-world policy knob and does not maintain
203+
destructive tool metadata from its native app tool annotations. `true`,
204+
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
205+
false. The `_default` app config is disabled with `open_world_enabled: false`.
206+
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
207+
not expose a separate plugin open-world policy knob and does not maintain
208208
per-plugin destructive tool-name deny lists.
209209

210210
Tool approval mode is automatic by default for plugin apps so non-destructive
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
225225
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
226226
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
227227
plugin approvals before returning the Codex approval response.
228+
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
229+
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
230+
app before the thread starts, and only offers one-shot approval or denial so
231+
durable approvals cannot suppress later write-action prompts.
228232
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
229233
id, or an unsafe elicitation schema declines instead of prompting.
230234

@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
272276
establishes a new harness session or replaces a stale binding.
273277

274278
**Destructive action is declined:** check the global and per-plugin
275-
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
276-
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
279+
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
280+
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
281+
closed.
277282

278283
## Related
279284

extensions/codex/doctor-contract-api.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
3636
},
3737
}),
3838
).toBe(false);
39+
expect(
40+
legacyConfigRules[1]?.match({
41+
allow_destructive_actions: "always",
42+
plugins: {
43+
"google-calendar": { allow_destructive_actions: "always" },
44+
},
45+
}),
46+
).toBe(false);
3947
});
4048

4149
it("removes the retired dynamic tools profile without dropping other Codex config", () => {

extensions/codex/openclaw.plugin.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"default": false
102102
},
103103
"allow_destructive_actions": {
104-
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
104+
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
105105
"default": true
106106
},
107107
"plugins": {
@@ -121,7 +121,7 @@
121121
"type": "string"
122122
},
123123
"allow_destructive_actions": {
124-
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
124+
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
125125
}
126126
}
127127
}
@@ -343,7 +343,7 @@
343343
},
344344
"codexPlugins.allow_destructive_actions": {
345345
"label": "Allow Destructive Plugin Actions",
346-
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
346+
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
347347
"advanced": true
348348
},
349349
"codexPlugins.plugins": {

extensions/codex/src/app-server/attempt-startup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
346346
timeoutMs: params.appServer.requestTimeoutMs,
347347
signal,
348348
}),
349+
configCwd: startupExecutionCwd,
349350
appCache: defaultCodexAppInventoryCache,
350351
appCacheKey: pluginAppCacheKey,
351352
}),

extensions/codex/src/app-server/config.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
11921192
});
11931193
});
11941194

1195+
it("parses always native Codex plugin destructive policy", () => {
1196+
const config = readCodexPluginConfig({
1197+
codexPlugins: {
1198+
enabled: true,
1199+
allow_destructive_actions: "always",
1200+
plugins: {
1201+
"google-calendar": {
1202+
marketplaceName: "openai-curated",
1203+
pluginName: "google-calendar",
1204+
},
1205+
slack: {
1206+
marketplaceName: "openai-curated",
1207+
pluginName: "slack",
1208+
allow_destructive_actions: "auto",
1209+
},
1210+
},
1211+
},
1212+
});
1213+
1214+
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
1215+
expect(resolveCodexPluginsPolicy(config)).toEqual({
1216+
configured: true,
1217+
enabled: true,
1218+
allowDestructiveActions: true,
1219+
destructiveApprovalMode: "always",
1220+
pluginPolicies: [
1221+
{
1222+
configKey: "google-calendar",
1223+
marketplaceName: "openai-curated",
1224+
pluginName: "google-calendar",
1225+
enabled: true,
1226+
allowDestructiveActions: true,
1227+
destructiveApprovalMode: "always",
1228+
},
1229+
{
1230+
configKey: "slack",
1231+
marketplaceName: "openai-curated",
1232+
pluginName: "slack",
1233+
enabled: true,
1234+
allowDestructiveActions: true,
1235+
destructiveApprovalMode: "auto",
1236+
},
1237+
],
1238+
});
1239+
});
1240+
11951241
it("rejects unsupported native Codex plugin destructive policy strings", () => {
11961242
const config = readCodexPluginConfig({
11971243
codexPlugins: {

extensions/codex/src/app-server/config.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
7474
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
7575
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
7676
export type CodexDynamicToolsLoading = "searchable" | "direct";
77-
export type CodexPluginDestructivePolicy = boolean | "auto";
78-
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
77+
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
78+
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
7979

8080
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
8181

@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
311311
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
312312
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
313313
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
314-
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
314+
const codexPluginDestructivePolicySchema = z.union([
315+
z.boolean(),
316+
z.literal("auto"),
317+
z.literal("always"),
318+
]);
315319
const codexAppServerServiceTierSchema = z
316320
.preprocess(
317321
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
495499
allowDestructiveActions: boolean;
496500
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
497501
} {
498-
if (policy === "auto") {
499-
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
502+
if (policy === "auto" || policy === "always") {
503+
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
500504
}
501505
return {
502506
allowDestructiveActions: policy,

extensions/codex/src/app-server/elicitation-bridge.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
157157
function createPluginAppPolicyContext(
158158
params: {
159159
allowDestructiveActions?: boolean;
160-
destructiveApprovalMode?: "allow" | "deny" | "auto";
160+
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
161161
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
162162
} = {},
163163
) {
@@ -1017,6 +1017,96 @@ describe("Codex app-server elicitation bridge", () => {
10171017
});
10181018
});
10191019

1020+
it("does not expose allow-always for always plugin policy", async () => {
1021+
mockCallGatewayTool
1022+
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
1023+
.mockResolvedValueOnce({
1024+
id: "plugin:approval-calendar-always-policy",
1025+
decision: "allow-once",
1026+
});
1027+
1028+
const result = await handleCodexAppServerElicitationRequest({
1029+
requestParams: buildConnectorPluginApprovalElicitation({
1030+
_meta: {
1031+
codex_approval_kind: "mcp_tool_call",
1032+
source: "connector",
1033+
connector_id: "connector_google_calendar",
1034+
connector_name: "Google Calendar",
1035+
persist: ["session", "always"],
1036+
tool_title: "create_event",
1037+
},
1038+
}),
1039+
paramsForRun: createParams(),
1040+
threadId: "thread-1",
1041+
turnId: "turn-1",
1042+
pluginAppPolicyContext: createPluginAppPolicyContext({
1043+
allowDestructiveActions: true,
1044+
destructiveApprovalMode: "always",
1045+
apps: [
1046+
{
1047+
appId: "connector_google_calendar",
1048+
pluginName: "google-calendar",
1049+
mcpServerNames: [],
1050+
},
1051+
],
1052+
}),
1053+
});
1054+
1055+
expect(result).toEqual({
1056+
action: "accept",
1057+
content: null,
1058+
_meta: null,
1059+
});
1060+
expect(gatewayToolArg(0, 2)).toMatchObject({
1061+
allowedDecisions: ["allow-once", "deny"],
1062+
});
1063+
});
1064+
1065+
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
1066+
mockCallGatewayTool
1067+
.mockResolvedValueOnce({
1068+
id: "plugin:approval-calendar-unexpected-always",
1069+
status: "accepted",
1070+
})
1071+
.mockResolvedValueOnce({
1072+
id: "plugin:approval-calendar-unexpected-always",
1073+
decision: "allow-always",
1074+
});
1075+
1076+
const result = await handleCodexAppServerElicitationRequest({
1077+
requestParams: buildConnectorPluginApprovalElicitation({
1078+
_meta: {
1079+
codex_approval_kind: "mcp_tool_call",
1080+
source: "connector",
1081+
connector_id: "connector_google_calendar",
1082+
connector_name: "Google Calendar",
1083+
persist: ["session", "always"],
1084+
tool_title: "create_event",
1085+
},
1086+
}),
1087+
paramsForRun: createParams(),
1088+
threadId: "thread-1",
1089+
turnId: "turn-1",
1090+
pluginAppPolicyContext: createPluginAppPolicyContext({
1091+
allowDestructiveActions: true,
1092+
destructiveApprovalMode: "always",
1093+
apps: [
1094+
{
1095+
appId: "connector_google_calendar",
1096+
pluginName: "google-calendar",
1097+
mcpServerNames: [],
1098+
},
1099+
],
1100+
}),
1101+
});
1102+
1103+
expect(result).toEqual({
1104+
action: "accept",
1105+
content: null,
1106+
_meta: null,
1107+
});
1108+
});
1109+
10201110
it("declines denied auto plugin app approvals", async () => {
10211111
mockCallGatewayTool
10221112
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })

extensions/codex/src/app-server/elicitation-bridge.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,21 +318,42 @@ async function buildPluginPolicyElicitationResponse(params: {
318318
paramsForRun: params.paramsForRun,
319319
title: approvalPrompt.title,
320320
description: approvalPrompt.description,
321-
allowedDecisions: approvalPrompt.allowedDecisions,
321+
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
322322
signal: params.signal,
323323
});
324-
return buildElicitationResponse(approvalPrompt, outcome);
324+
return buildElicitationResponse(
325+
approvalPrompt,
326+
oneShotPluginPolicyApprovalOutcome(mode, outcome),
327+
);
325328
}
326329
logPluginElicitationDecline("unmappable_schema", params.requestParams);
327330
return declineElicitationResponse();
328331
}
329332

330333
function resolvePluginDestructiveApprovalMode(
331334
entry: PluginAppPolicyContextEntry,
332-
): "allow" | "deny" | "auto" {
335+
): "allow" | "deny" | "auto" | "always" {
333336
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
334337
}
335338

339+
function allowedPluginPolicyApprovalDecisions(
340+
mode: "allow" | "deny" | "auto" | "always",
341+
approvalPrompt: BridgeableApprovalElicitation,
342+
): ExecApprovalDecision[] {
343+
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
344+
if (mode !== "always") {
345+
return allowedDecisions;
346+
}
347+
return allowedDecisions.filter((decision) => decision !== "allow-always");
348+
}
349+
350+
function oneShotPluginPolicyApprovalOutcome(
351+
mode: "allow" | "deny" | "auto" | "always",
352+
outcome: AppServerApprovalOutcome,
353+
): AppServerApprovalOutcome {
354+
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
355+
}
356+
336357
function readPluginApprovalElicitation(
337358
entry: PluginAppPolicyContextEntry,
338359
requestParams: JsonObject,

0 commit comments

Comments
 (0)