Skip to content

Commit 72f837a

Browse files
authored
fix(codex): require admin for native controls (#97952)
* fix(codex): require admin for native controls Gate Codex native session controls and bound turns on current owner or operator.admin authority. Preserve gateway scope precedence and read-only status behavior. * fix(codex): align native authorization * fix(codex): preserve silent bound handling * fix(codex): narrow bound auth contract * fix(docs): refresh generated docs map
1 parent 54b0958 commit 72f837a

10 files changed

Lines changed: 216 additions & 9 deletions

docs/docs_map.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8448,6 +8448,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
84488448
- H2: What the wizard writes
84498449
- H2: Related docs
84508450

8451+
## releases/index.md
8452+
8453+
- Route: /releases
8454+
- Headings:
8455+
- H1: Release notes
8456+
- H2: Coming soon
8457+
- H2: Raw release history
8458+
84518459
## security/CONTRIBUTING-THREAT-MODEL.md
84528460

84538461
- Route: /security/CONTRIBUTING-THREAT-MODEL

docs/plugins/codex-harness.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,12 @@ timeout behavior, see [Codex harness reference](/plugins/codex-harness-reference
404404
The bundled plugin registers `/codex` as a slash command on any channel that
405405
supports OpenClaw text commands.
406406

407+
Native execution and control require an owner or an `operator.admin` Gateway
408+
client. This includes binding or resuming threads, sending or stopping turns,
409+
changing model, fast-mode, or permission state, compacting or reviewing, and
410+
detaching a binding. Other authorized senders retain read-only status, help,
411+
account, model, thread, MCP server, skill, and binding inspection commands.
412+
407413
Common forms:
408414

409415
- `/codex status` checks app-server connectivity, models, account, rate limits,
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
22

3-
export function canMutateCodexHost(ctx: PluginCommandContext): boolean {
3+
type CodexHostMutationAuthContext = Pick<
4+
PluginCommandContext,
5+
"gatewayClientScopes" | "senderIsOwner"
6+
>;
7+
8+
export const CODEX_NATIVE_EXECUTION_AUTH_ERROR =
9+
"Only an owner or operator.admin can control Codex native execution.";
10+
11+
export function canMutateCodexHost(ctx: CodexHostMutationAuthContext): boolean {
412
return ctx.senderIsOwner === true || ctx.gatewayClientScopes?.includes("operator.admin") === true;
513
}

extensions/codex/src/command-handlers.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
writeCodexAppServerBinding,
2525
} from "./app-server/session-binding.js";
2626
import { readCodexAccountAuthOverview } from "./command-account.js";
27-
import { canMutateCodexHost } from "./command-authorization.js";
27+
import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js";
2828
import {
2929
buildHelp,
3030
formatAccount,
@@ -233,6 +233,12 @@ const CODEX_NATIVE_EXECUTION_SUBCOMMANDS = new Set([
233233
"compact",
234234
"review",
235235
]);
236+
const CODEX_NATIVE_CONTROL_SUBCOMMANDS = new Set([
237+
...CODEX_NATIVE_EXECUTION_SUBCOMMANDS,
238+
"detach",
239+
"unbind",
240+
"stop",
241+
]);
236242

237243
const lastCodexDiagnosticsUploadByThread = new Map<string, number>();
238244
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
@@ -378,6 +384,13 @@ export async function handleCodexSubcommand(
378384
if (normalized === "help") {
379385
return { text: buildHelp() };
380386
}
387+
if (
388+
CODEX_NATIVE_CONTROL_SUBCOMMANDS.has(normalized) &&
389+
!returnsBeforeNativeCodexExecution(normalized, rest) &&
390+
!canMutateCodexHost(ctx)
391+
) {
392+
return { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR };
393+
}
381394
const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest);
382395
if (sandboxBlock) {
383396
return { text: sandboxBlock };
@@ -603,6 +616,8 @@ function returnsBeforeNativeCodexExecution(subcommand: string, args: readonly st
603616
);
604617
case "compact":
605618
case "review":
619+
case "detach":
620+
case "unbind":
606621
case "stop":
607622
return args.length > 0;
608623
default:
@@ -1000,15 +1015,15 @@ async function setConversationPermissions(
10001015
if (args.length > 1) {
10011016
return "Usage: /codex permissions [default|yolo|status]";
10021017
}
1003-
const target = await resolveControlTarget(ctx);
1004-
if (!target) {
1005-
return "Cannot set Codex permissions because this command did not include an OpenClaw session file.";
1006-
}
10071018
const value = args[0];
10081019
const parsed = parseCodexPermissionsModeArg(value);
10091020
if (value && !parsed && value.trim().toLowerCase() !== "status") {
10101021
return "Usage: /codex permissions [default|yolo|status]";
10111022
}
1023+
const target = await resolveControlTarget(ctx);
1024+
if (!target) {
1025+
return "Cannot set Codex permissions because this command did not include an OpenClaw session file.";
1026+
}
10121027
return await deps.setCodexConversationPermissions({
10131028
sessionFile: target.sessionFile,
10141029
pluginConfig,

extensions/codex/src/commands.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3878,6 +3878,106 @@ describe("codex command", () => {
38783878
});
38793879
});
38803880

3881+
it("requires an owner or operator.admin for Codex binding and permission changes", async () => {
3882+
const sessionFile = path.join(tempDir, "session.jsonl");
3883+
const startCodexConversationThread = vi.fn();
3884+
const codexControlRequest = vi.fn();
3885+
const resolveCodexCliSessionForBindingOnNode = vi.fn();
3886+
const stopCodexConversationTurn = vi.fn();
3887+
const steerCodexConversationTurn = vi.fn();
3888+
const setCodexConversationModel = vi.fn();
3889+
const setCodexConversationFastMode = vi.fn();
3890+
const setCodexConversationPermissions = vi.fn(
3891+
async () => "Codex permissions set to full access.",
3892+
);
3893+
const cases = [
3894+
["bind", createDeps({ startCodexConversationThread }), startCodexConversationThread],
3895+
["resume thread-123", createDeps({ codexControlRequest }), codexControlRequest],
3896+
[
3897+
"resume cli-session --host worker-1 --bind here",
3898+
createDeps({ resolveCodexCliSessionForBindingOnNode }),
3899+
resolveCodexCliSessionForBindingOnNode,
3900+
],
3901+
["stop", createDeps({ stopCodexConversationTurn }), stopCodexConversationTurn],
3902+
[
3903+
"permissions yolo",
3904+
createDeps({ setCodexConversationPermissions }),
3905+
setCodexConversationPermissions,
3906+
],
3907+
["steer continue", createDeps({ steerCodexConversationTurn }), steerCodexConversationTurn],
3908+
["model gpt-5.5", createDeps({ setCodexConversationModel }), setCodexConversationModel],
3909+
["fast on", createDeps({ setCodexConversationFastMode }), setCodexConversationFastMode],
3910+
["compact", createDeps({ codexControlRequest }), codexControlRequest],
3911+
["review", createDeps({ codexControlRequest }), codexControlRequest],
3912+
] as const;
3913+
3914+
for (const [args, deps, sideEffect] of cases) {
3915+
await expect(
3916+
handleCodexCommand(
3917+
createContext(args, sessionFile, {
3918+
senderIsOwner: false,
3919+
gatewayClientScopes: ["operator.write"],
3920+
}),
3921+
{ deps },
3922+
),
3923+
).resolves.toEqual({
3924+
text: "Only an owner or operator.admin can control Codex native execution.",
3925+
});
3926+
expect(sideEffect).not.toHaveBeenCalled();
3927+
}
3928+
3929+
const detachConversationBinding = vi.fn();
3930+
for (const args of ["detach", "unbind"]) {
3931+
await expect(
3932+
handleCodexCommand(
3933+
createContext(args, sessionFile, {
3934+
senderIsOwner: false,
3935+
gatewayClientScopes: ["operator.write"],
3936+
detachConversationBinding,
3937+
}),
3938+
{ deps: createDeps() },
3939+
),
3940+
).resolves.toEqual({
3941+
text: "Only an owner or operator.admin can control Codex native execution.",
3942+
});
3943+
}
3944+
expect(detachConversationBinding).not.toHaveBeenCalled();
3945+
3946+
const readCodexPermissions = vi.fn(async () => "Codex permissions: full access.");
3947+
await expect(
3948+
handleCodexCommand(
3949+
createContext("permissions status", sessionFile, {
3950+
senderIsOwner: false,
3951+
gatewayClientScopes: ["operator.write"],
3952+
}),
3953+
{ deps: createDeps({ setCodexConversationPermissions: readCodexPermissions }) },
3954+
),
3955+
).resolves.toEqual({ text: "Codex permissions: full access." });
3956+
expect(readCodexPermissions).toHaveBeenCalledTimes(1);
3957+
3958+
await expect(
3959+
handleCodexCommand(
3960+
createContext("permissions yolo", sessionFile, {
3961+
senderIsOwner: true,
3962+
gatewayClientScopes: ["operator.write"],
3963+
}),
3964+
{ deps: createDeps({ setCodexConversationPermissions }) },
3965+
),
3966+
).resolves.toEqual({ text: "Codex permissions set to full access." });
3967+
expect(setCodexConversationPermissions).toHaveBeenCalledTimes(1);
3968+
3969+
await expect(
3970+
handleCodexCommand(
3971+
createContext("permissions yolo", sessionFile, {
3972+
senderIsOwner: false,
3973+
gatewayClientScopes: ["operator.admin"],
3974+
}),
3975+
{ deps: createDeps({ setCodexConversationPermissions }) },
3976+
),
3977+
).resolves.toEqual({ text: "Codex permissions set to full access." });
3978+
expect(setCodexConversationPermissions).toHaveBeenCalledTimes(2);
3979+
});
3980+
38813981
it("escapes current bound model status before chat display", async () => {
38823982
const sessionFile = path.join(tempDir, "session.jsonl");
38833983
await fs.writeFile(

extensions/codex/src/conversation-binding.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,16 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks);
7373
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
7474
import {
7575
handleCodexConversationBindingResolved,
76-
handleCodexConversationInboundClaim,
76+
handleCodexConversationInboundClaim as handleCodexConversationInboundClaimImpl,
7777
startCodexConversationThread,
7878
} from "./conversation-binding.js";
7979

80+
function handleCodexConversationInboundClaim(
81+
...[event, ...rest]: Parameters<typeof handleCodexConversationInboundClaimImpl>
82+
) {
83+
return handleCodexConversationInboundClaimImpl({ senderIsOwner: true, ...event }, ...rest);
84+
}
85+
8086
let tempDir: string;
8187

8288
const NETWORK_PROXY_PLUGIN_CONFIG = {
@@ -574,6 +580,7 @@ describe("codex conversation binding", () => {
574580
content: "run this",
575581
channel: "discord",
576582
isGroup: true,
583+
senderIsOwner: false,
577584
},
578585
{
579586
channelId: "discord",
@@ -598,6 +605,42 @@ describe("codex conversation binding", () => {
598605
expect(result).toEqual({ handled: true });
599606
});
600607

608+
it("blocks inbound bound turns without current owner or admin authority", async () => {
609+
const result = await handleCodexConversationInboundClaim(
610+
{
611+
content: "run this",
612+
channel: "discord",
613+
isGroup: true,
614+
commandAuthorized: true,
615+
senderIsOwner: false,
616+
},
617+
{
618+
channelId: "discord",
619+
pluginBinding: {
620+
bindingId: "binding-1",
621+
pluginId: "codex",
622+
pluginRoot: tempDir,
623+
channel: "discord",
624+
accountId: "default",
625+
conversationId: "channel-1",
626+
boundAt: Date.now(),
627+
data: {
628+
kind: "codex-app-server-session",
629+
version: 1,
630+
sessionFile: path.join(tempDir, "session.jsonl"),
631+
workspaceDir: tempDir,
632+
},
633+
},
634+
},
635+
);
636+
637+
expect(result).toEqual({
638+
handled: true,
639+
reply: { text: "Only an owner or operator.admin can control Codex native execution." },
640+
});
641+
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
642+
});
643+
601644
it("routes bound Codex CLI node sessions through node resume", async () => {
602645
const resumeCodexCliSessionOnNode = vi.fn(async () => ({
603646
ok: true as const,

extensions/codex/src/conversation-binding.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
CODEX_NATIVE_PERSONALITY_NONE,
5454
resolveCodexAppServerRequestModelSelection,
5555
} from "./app-server/thread-lifecycle.js";
56+
import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js";
5657
import { formatCodexDisplayText } from "./command-formatters.js";
5758
import {
5859
createCodexConversationBindingData,
@@ -244,6 +245,9 @@ export async function handleCodexConversationInboundClaim(
244245
if (!prompt) {
245246
return { handled: true };
246247
}
248+
if (!canMutateCodexHost(event)) {
249+
return { handled: true, reply: { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR } };
250+
}
247251
const nativeExecutionBlock =
248252
data.kind === "codex-cli-node-session"
249253
? resolveCodexNativeSandboxBlock({

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6576,6 +6576,8 @@ describe("dispatchReplyFromConfig", () => {
65766576
AccountId: "default",
65776577
SenderId: "user-9",
65786578
SenderUsername: "ada",
6579+
OwnerAllowFrom: ["user-9"],
6580+
GatewayClientScopes: ["operator.write"],
65796581
CommandAuthorized: true,
65806582
WasMentioned: false,
65816583
CommandBody: "who are you",
@@ -6594,7 +6596,13 @@ describe("dispatchReplyFromConfig", () => {
65946596
.calls[0] as unknown as
65956597
| [
65966598
unknown,
6597-
{ accountId?: unknown; channel?: unknown; content?: unknown; conversationId?: unknown },
6599+
{
6600+
accountId?: unknown;
6601+
channel?: unknown;
6602+
content?: unknown;
6603+
conversationId?: unknown;
6604+
senderIsOwner?: unknown;
6605+
},
65986606
{
65996607
accountId?: unknown;
66006608
channelId?: unknown;
@@ -6608,6 +6616,8 @@ describe("dispatchReplyFromConfig", () => {
66086616
expect(inboundClaimCall?.[1]?.accountId).toBe("default");
66096617
expect(inboundClaimCall?.[1]?.conversationId).toBe("channel:1481858418548412579");
66106618
expect(inboundClaimCall?.[1]?.content).toBe("who are you");
6619+
expect(inboundClaimCall?.[1]?.senderIsOwner).toBe(true);
6620+
expect(inboundClaimCall?.[1]).not.toHaveProperty("gatewayClientScopes");
66116621
expect(inboundClaimCall?.[2]?.channelId).toBe("discord");
66126622
expect(inboundClaimCall?.[2]?.accountId).toBe("default");
66136623
expect(inboundClaimCall?.[2]?.conversationId).toBe("channel:1481858418548412579");

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import {
104104
shouldAttemptTtsPayload,
105105
} from "../../tts/tts-config.js";
106106
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
107+
import { resolveCommandAuthorization } from "../command-auth.js";
107108
import {
108109
isNativeCommandTurn,
109110
resolveCommandTurnContext,
@@ -2148,12 +2149,23 @@ export async function dispatchReplyFromConfig(
21482149
logVerbose(
21492150
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
21502151
);
2152+
// Bound native runtimes need the current owner decision, not stale bind-time identity.
2153+
// The resolver folds internal operator.admin authority into this owner decision.
2154+
const bindingAuthorization = resolveCommandAuthorization({
2155+
ctx,
2156+
cfg,
2157+
commandAuthorized: ctx.CommandAuthorized,
2158+
});
21512159
const targetedClaimOutcome = hookRunner?.runInboundClaimForPluginOutcome
21522160
? await (async () => {
21532161
await prepareHookMediaMetadata();
2162+
const authorizedInboundClaimEvent = {
2163+
...inboundClaimEvent,
2164+
senderIsOwner: bindingAuthorization.senderIsOwner,
2165+
};
21542166
return hookRunner.runInboundClaimForPluginOutcome(
21552167
pluginOwnedBinding.pluginId,
2156-
inboundClaimEvent,
2168+
authorizedInboundClaimEvent,
21572169
{ ...inboundClaimContext, pluginBinding: pluginOwnedBinding },
21582170
);
21592171
})()

src/plugins/hook-message.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export type PluginHookInboundClaimEvent = {
9191
parentSpanId?: string;
9292
isGroup: boolean;
9393
commandAuthorized?: boolean;
94+
senderIsOwner?: boolean;
9495
wasMentioned?: boolean;
9596
metadata?: Record<string, unknown>;
9697
};

0 commit comments

Comments
 (0)