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
This commit is contained in:
Agustin Rivera
2026-06-29 20:41:28 -07:00
committed by GitHub
parent 54b09580f6
commit 72f837a4a4
10 changed files with 216 additions and 9 deletions
+8
View File
@@ -8448,6 +8448,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: What the wizard writes
- H2: Related docs
## releases/index.md
- Route: /releases
- Headings:
- H1: Release notes
- H2: Coming soon
- H2: Raw release history
## security/CONTRIBUTING-THREAT-MODEL.md
- Route: /security/CONTRIBUTING-THREAT-MODEL
+6
View File
@@ -404,6 +404,12 @@ timeout behavior, see [Codex harness reference](/plugins/codex-harness-reference
The bundled plugin registers `/codex` as a slash command on any channel that
supports OpenClaw text commands.
Native execution and control require an owner or an `operator.admin` Gateway
client. This includes binding or resuming threads, sending or stopping turns,
changing model, fast-mode, or permission state, compacting or reviewing, and
detaching a binding. Other authorized senders retain read-only status, help,
account, model, thread, MCP server, skill, and binding inspection commands.
Common forms:
- `/codex status` checks app-server connectivity, models, account, rate limits,
@@ -1,5 +1,13 @@
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
export function canMutateCodexHost(ctx: PluginCommandContext): boolean {
type CodexHostMutationAuthContext = Pick<
PluginCommandContext,
"gatewayClientScopes" | "senderIsOwner"
>;
export const CODEX_NATIVE_EXECUTION_AUTH_ERROR =
"Only an owner or operator.admin can control Codex native execution.";
export function canMutateCodexHost(ctx: CodexHostMutationAuthContext): boolean {
return ctx.senderIsOwner === true || ctx.gatewayClientScopes?.includes("operator.admin") === true;
}
+20 -5
View File
@@ -24,7 +24,7 @@ import {
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
import { readCodexAccountAuthOverview } from "./command-account.js";
import { canMutateCodexHost } from "./command-authorization.js";
import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js";
import {
buildHelp,
formatAccount,
@@ -233,6 +233,12 @@ const CODEX_NATIVE_EXECUTION_SUBCOMMANDS = new Set([
"compact",
"review",
]);
const CODEX_NATIVE_CONTROL_SUBCOMMANDS = new Set([
...CODEX_NATIVE_EXECUTION_SUBCOMMANDS,
"detach",
"unbind",
"stop",
]);
const lastCodexDiagnosticsUploadByThread = new Map<string, number>();
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
@@ -378,6 +384,13 @@ export async function handleCodexSubcommand(
if (normalized === "help") {
return { text: buildHelp() };
}
if (
CODEX_NATIVE_CONTROL_SUBCOMMANDS.has(normalized) &&
!returnsBeforeNativeCodexExecution(normalized, rest) &&
!canMutateCodexHost(ctx)
) {
return { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR };
}
const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest);
if (sandboxBlock) {
return { text: sandboxBlock };
@@ -603,6 +616,8 @@ function returnsBeforeNativeCodexExecution(subcommand: string, args: readonly st
);
case "compact":
case "review":
case "detach":
case "unbind":
case "stop":
return args.length > 0;
default:
@@ -1000,15 +1015,15 @@ async function setConversationPermissions(
if (args.length > 1) {
return "Usage: /codex permissions [default|yolo|status]";
}
const target = await resolveControlTarget(ctx);
if (!target) {
return "Cannot set Codex permissions because this command did not include an OpenClaw session file.";
}
const value = args[0];
const parsed = parseCodexPermissionsModeArg(value);
if (value && !parsed && value.trim().toLowerCase() !== "status") {
return "Usage: /codex permissions [default|yolo|status]";
}
const target = await resolveControlTarget(ctx);
if (!target) {
return "Cannot set Codex permissions because this command did not include an OpenClaw session file.";
}
return await deps.setCodexConversationPermissions({
sessionFile: target.sessionFile,
pluginConfig,
+100
View File
@@ -3878,6 +3878,106 @@ describe("codex command", () => {
});
});
it("requires an owner or operator.admin for Codex binding and permission changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const startCodexConversationThread = vi.fn();
const codexControlRequest = vi.fn();
const resolveCodexCliSessionForBindingOnNode = vi.fn();
const stopCodexConversationTurn = vi.fn();
const steerCodexConversationTurn = vi.fn();
const setCodexConversationModel = vi.fn();
const setCodexConversationFastMode = vi.fn();
const setCodexConversationPermissions = vi.fn(
async () => "Codex permissions set to full access.",
);
const cases = [
["bind", createDeps({ startCodexConversationThread }), startCodexConversationThread],
["resume thread-123", createDeps({ codexControlRequest }), codexControlRequest],
[
"resume cli-session --host worker-1 --bind here",
createDeps({ resolveCodexCliSessionForBindingOnNode }),
resolveCodexCliSessionForBindingOnNode,
],
["stop", createDeps({ stopCodexConversationTurn }), stopCodexConversationTurn],
[
"permissions yolo",
createDeps({ setCodexConversationPermissions }),
setCodexConversationPermissions,
],
["steer continue", createDeps({ steerCodexConversationTurn }), steerCodexConversationTurn],
["model gpt-5.5", createDeps({ setCodexConversationModel }), setCodexConversationModel],
["fast on", createDeps({ setCodexConversationFastMode }), setCodexConversationFastMode],
["compact", createDeps({ codexControlRequest }), codexControlRequest],
["review", createDeps({ codexControlRequest }), codexControlRequest],
] as const;
for (const [args, deps, sideEffect] of cases) {
await expect(
handleCodexCommand(
createContext(args, sessionFile, {
senderIsOwner: false,
gatewayClientScopes: ["operator.write"],
}),
{ deps },
),
).resolves.toEqual({
text: "Only an owner or operator.admin can control Codex native execution.",
});
expect(sideEffect).not.toHaveBeenCalled();
}
const detachConversationBinding = vi.fn();
for (const args of ["detach", "unbind"]) {
await expect(
handleCodexCommand(
createContext(args, sessionFile, {
senderIsOwner: false,
gatewayClientScopes: ["operator.write"],
detachConversationBinding,
}),
{ deps: createDeps() },
),
).resolves.toEqual({
text: "Only an owner or operator.admin can control Codex native execution.",
});
}
expect(detachConversationBinding).not.toHaveBeenCalled();
const readCodexPermissions = vi.fn(async () => "Codex permissions: full access.");
await expect(
handleCodexCommand(
createContext("permissions status", sessionFile, {
senderIsOwner: false,
gatewayClientScopes: ["operator.write"],
}),
{ deps: createDeps({ setCodexConversationPermissions: readCodexPermissions }) },
),
).resolves.toEqual({ text: "Codex permissions: full access." });
expect(readCodexPermissions).toHaveBeenCalledTimes(1);
await expect(
handleCodexCommand(
createContext("permissions yolo", sessionFile, {
senderIsOwner: true,
gatewayClientScopes: ["operator.write"],
}),
{ deps: createDeps({ setCodexConversationPermissions }) },
),
).resolves.toEqual({ text: "Codex permissions set to full access." });
expect(setCodexConversationPermissions).toHaveBeenCalledTimes(1);
await expect(
handleCodexCommand(
createContext("permissions yolo", sessionFile, {
senderIsOwner: false,
gatewayClientScopes: ["operator.admin"],
}),
{ deps: createDeps({ setCodexConversationPermissions }) },
),
).resolves.toEqual({ text: "Codex permissions set to full access." });
expect(setCodexConversationPermissions).toHaveBeenCalledTimes(2);
});
it("escapes current bound model status before chat display", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
@@ -73,10 +73,16 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks);
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
handleCodexConversationInboundClaim as handleCodexConversationInboundClaimImpl,
startCodexConversationThread,
} from "./conversation-binding.js";
function handleCodexConversationInboundClaim(
...[event, ...rest]: Parameters<typeof handleCodexConversationInboundClaimImpl>
) {
return handleCodexConversationInboundClaimImpl({ senderIsOwner: true, ...event }, ...rest);
}
let tempDir: string;
const NETWORK_PROXY_PLUGIN_CONFIG = {
@@ -574,6 +580,7 @@ describe("codex conversation binding", () => {
content: "run this",
channel: "discord",
isGroup: true,
senderIsOwner: false,
},
{
channelId: "discord",
@@ -598,6 +605,42 @@ describe("codex conversation binding", () => {
expect(result).toEqual({ handled: true });
});
it("blocks inbound bound turns without current owner or admin authority", async () => {
const result = await handleCodexConversationInboundClaim(
{
content: "run this",
channel: "discord",
isGroup: true,
commandAuthorized: true,
senderIsOwner: false,
},
{
channelId: "discord",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "discord",
accountId: "default",
conversationId: "channel-1",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile: path.join(tempDir, "session.jsonl"),
workspaceDir: tempDir,
},
},
},
);
expect(result).toEqual({
handled: true,
reply: { text: "Only an owner or operator.admin can control Codex native execution." },
});
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("routes bound Codex CLI node sessions through node resume", async () => {
const resumeCodexCliSessionOnNode = vi.fn(async () => ({
ok: true as const,
@@ -53,6 +53,7 @@ import {
CODEX_NATIVE_PERSONALITY_NONE,
resolveCodexAppServerRequestModelSelection,
} from "./app-server/thread-lifecycle.js";
import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js";
import { formatCodexDisplayText } from "./command-formatters.js";
import {
createCodexConversationBindingData,
@@ -244,6 +245,9 @@ export async function handleCodexConversationInboundClaim(
if (!prompt) {
return { handled: true };
}
if (!canMutateCodexHost(event)) {
return { handled: true, reply: { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR } };
}
const nativeExecutionBlock =
data.kind === "codex-cli-node-session"
? resolveCodexNativeSandboxBlock({
@@ -6576,6 +6576,8 @@ describe("dispatchReplyFromConfig", () => {
AccountId: "default",
SenderId: "user-9",
SenderUsername: "ada",
OwnerAllowFrom: ["user-9"],
GatewayClientScopes: ["operator.write"],
CommandAuthorized: true,
WasMentioned: false,
CommandBody: "who are you",
@@ -6594,7 +6596,13 @@ describe("dispatchReplyFromConfig", () => {
.calls[0] as unknown as
| [
unknown,
{ accountId?: unknown; channel?: unknown; content?: unknown; conversationId?: unknown },
{
accountId?: unknown;
channel?: unknown;
content?: unknown;
conversationId?: unknown;
senderIsOwner?: unknown;
},
{
accountId?: unknown;
channelId?: unknown;
@@ -6608,6 +6616,8 @@ describe("dispatchReplyFromConfig", () => {
expect(inboundClaimCall?.[1]?.accountId).toBe("default");
expect(inboundClaimCall?.[1]?.conversationId).toBe("channel:1481858418548412579");
expect(inboundClaimCall?.[1]?.content).toBe("who are you");
expect(inboundClaimCall?.[1]?.senderIsOwner).toBe(true);
expect(inboundClaimCall?.[1]).not.toHaveProperty("gatewayClientScopes");
expect(inboundClaimCall?.[2]?.channelId).toBe("discord");
expect(inboundClaimCall?.[2]?.accountId).toBe("default");
expect(inboundClaimCall?.[2]?.conversationId).toBe("channel:1481858418548412579");
+13 -1
View File
@@ -104,6 +104,7 @@ import {
shouldAttemptTtsPayload,
} from "../../tts/tts-config.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import {
isNativeCommandTurn,
resolveCommandTurnContext,
@@ -2148,12 +2149,23 @@ export async function dispatchReplyFromConfig(
logVerbose(
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
);
// Bound native runtimes need the current owner decision, not stale bind-time identity.
// The resolver folds internal operator.admin authority into this owner decision.
const bindingAuthorization = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: ctx.CommandAuthorized,
});
const targetedClaimOutcome = hookRunner?.runInboundClaimForPluginOutcome
? await (async () => {
await prepareHookMediaMetadata();
const authorizedInboundClaimEvent = {
...inboundClaimEvent,
senderIsOwner: bindingAuthorization.senderIsOwner,
};
return hookRunner.runInboundClaimForPluginOutcome(
pluginOwnedBinding.pluginId,
inboundClaimEvent,
authorizedInboundClaimEvent,
{ ...inboundClaimContext, pluginBinding: pluginOwnedBinding },
);
})()
+1
View File
@@ -91,6 +91,7 @@ export type PluginHookInboundClaimEvent = {
parentSpanId?: string;
isGroup: boolean;
commandAuthorized?: boolean;
senderIsOwner?: boolean;
wasMentioned?: boolean;
metadata?: Record<string, unknown>;
};