feat(codex): add guardian app-server mode (#70090)

Reworks the Codex app-server Guardian change into the final landing shape:

- keep YOLO as the default local app-server mode
- add explicit `appServer.mode: "guardian"`
- remove the legacy `OPENCLAW_CODEX_APP_SERVER_GUARDIAN` shortcut
- document Guardian configuration and behavior
- add Guardian event projection and Docker live probes for approved/ask-back decisions

Co-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
pashpashpash
2026-04-22 16:25:43 -07:00
committed by GitHub
parent 34e45ecfcc
commit ff02563c7c
15 changed files with 482 additions and 38 deletions

View File

@@ -34,6 +34,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["yolo", "guardian"],
"default": "yolo"
},
"transport": {
"type": "string",
"enum": ["stdio", "websocket"],
@@ -102,6 +107,11 @@
"help": "Runtime controls for connecting to Codex app-server.",
"advanced": true
},
"appServer.mode": {
"label": "Execution Mode",
"help": "Use yolo for unchained local execution or guardian for Codex guardian-reviewed approvals.",
"advanced": true
},
"appServer.transport": {
"label": "Transport",
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",

View File

@@ -6,6 +6,7 @@ import {
CodexAppServerClient,
CodexAppServerRpcError,
MIN_CODEX_APP_SERVER_VERSION,
isCodexAppServerApprovalRequest,
readCodexVersionFromUserAgent,
} from "./client.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
@@ -244,4 +245,12 @@ describe("CodexAppServerClient", () => {
result: { decision: "decline" },
});
});
it("only treats known Codex app-server approval methods as approvals", () => {
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/permissions/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("evil/Approval")).toBe(false);
expect(isCodexAppServerApprovalRequest("item/tool/requestApproval")).toBe(false);
});
});

View File

@@ -416,8 +416,14 @@ function numericVersionParts(version: string): number[] {
.map((part) => (Number.isFinite(part) ? part : 0));
}
const CODEX_APP_SERVER_APPROVAL_REQUEST_METHODS = new Set([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
"item/permissions/requestApproval",
]);
export function isCodexAppServerApprovalRequest(method: string): boolean {
return method.includes("requestApproval") || method.includes("Approval");
return CODEX_APP_SERVER_APPROVAL_REQUEST_METHODS.has(method);
}
function formatExitValue(value: unknown): string {

View File

@@ -12,6 +12,7 @@ describe("Codex app-server config", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
mode: "guardian",
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
@@ -76,6 +77,77 @@ describe("Codex app-server config", () => {
);
});
it("allows plugin config to opt in to guardian-reviewed local execution", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
mode: "guardian",
},
},
env: {},
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "guardian_subagent",
}),
);
});
it("allows environment mode fallback to opt in to guardian-reviewed local execution", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {},
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "guardian_subagent",
}),
);
});
it("ignores removed OPENCLAW_CODEX_APP_SERVER_GUARDIAN fallback", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {},
env: { OPENCLAW_CODEX_APP_SERVER_GUARDIAN: "1" },
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("lets explicit policy fields override guardian mode", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
mode: "guardian",
approvalPolicy: "on-failure",
sandbox: "danger-full-access",
approvalsReviewer: "user",
},
},
env: {},
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-failure",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("derives distinct shared-client keys for distinct auth tokens without exposing them", () => {
const first = codexAppServerStartOptionsKey({
transport: "websocket",

View File

@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { z } from "zod";
export type CodexAppServerTransportMode = "stdio" | "websocket";
export type CodexAppServerPolicyMode = "yolo" | "guardian";
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexAppServerApprovalsReviewer = "user" | "guardian_subagent";
@@ -32,6 +33,7 @@ export type CodexPluginConfig = {
timeoutMs?: number;
};
appServer?: {
mode?: CodexAppServerPolicyMode;
transport?: CodexAppServerTransportMode;
command?: string;
args?: string[] | string;
@@ -47,6 +49,7 @@ export type CodexPluginConfig = {
};
export const CODEX_APP_SERVER_CONFIG_KEYS = [
"mode",
"transport",
"command",
"args",
@@ -61,6 +64,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
] as const;
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
const codexAppServerApprovalPolicySchema = z.enum([
"never",
"on-request",
@@ -81,6 +85,7 @@ const codexPluginConfigSchema = z
.optional(),
appServer: z
.object({
mode: codexAppServerPolicyModeSchema.optional(),
transport: codexAppServerTransportSchema.optional(),
command: z.string().optional(),
args: z.union([z.array(z.string()), z.string()]).optional(),
@@ -118,6 +123,10 @@ export function resolveCodexAppServerRuntimeOptions(
const headers = normalizeHeaders(config.headers);
const authToken = readNonEmptyString(config.authToken);
const url = readNonEmptyString(config.url);
const policyMode =
resolvePolicyMode(config.mode) ??
resolvePolicyMode(env.OPENCLAW_CODEX_APP_SERVER_MODE) ??
"yolo";
if (transport === "websocket" && !url) {
throw new Error(
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
@@ -137,14 +146,14 @@ export function resolveCodexAppServerRuntimeOptions(
approvalPolicy:
resolveApprovalPolicy(config.approvalPolicy) ??
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??
"never",
(policyMode === "guardian" ? "on-request" : "never"),
sandbox:
resolveSandbox(config.sandbox) ??
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
"danger-full-access",
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
approvalsReviewer:
resolveApprovalsReviewer(config.approvalsReviewer) ??
(env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user"),
(policyMode === "guardian" ? "guardian_subagent" : "user"),
...(readNonEmptyString(config.serviceTier)
? { serviceTier: readNonEmptyString(config.serviceTier) }
: {}),
@@ -170,6 +179,10 @@ function resolveTransport(value: unknown): CodexAppServerTransportMode {
return value === "websocket" ? "websocket" : "stdio";
}
function resolvePolicyMode(value: unknown): CodexAppServerPolicyMode | undefined {
return value === "guardian" || value === "yolo" ? value : undefined;
}
function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "on-request" ||
value === "on-failure" ||

View File

@@ -314,6 +314,74 @@ describe("CodexAppServerEventProjector", () => {
expect(result.yieldDetected).toBe(true);
});
it("projects guardian review lifecycle details into agent events", async () => {
const onAgentEvent = vi.fn();
const projector = createProjector({ ...createParams(), onAgentEvent });
await projector.handleNotification(
forCurrentTurn("item/autoApprovalReview/started", {
reviewId: "review-1",
targetItemId: "cmd-1",
review: { status: "inProgress" },
action: {
type: "execve",
source: "shell",
program: "/bin/printf",
argv: ["printf", "hello"],
cwd: "/tmp",
},
}),
);
await projector.handleNotification(
forCurrentTurn("item/autoApprovalReview/completed", {
reviewId: "review-1",
targetItemId: "cmd-1",
decisionSource: "agent",
review: {
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
},
action: {
type: "execve",
source: "shell",
program: "/bin/printf",
argv: ["printf", "hello"],
cwd: "/tmp",
},
}),
);
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.guardian",
data: expect.objectContaining({
phase: "started",
reviewId: "review-1",
targetItemId: "cmd-1",
status: "inProgress",
actionType: "execve",
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.guardian",
data: expect.objectContaining({
phase: "completed",
reviewId: "review-1",
targetItemId: "cmd-1",
decisionSource: "agent",
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
actionType: "execve",
}),
});
expect(
projector.buildResult(buildEmptyToolTelemetry()).didSendDeterministicApprovalPrompt,
).toBe(false);
});
it("projects reasoning end, plan updates, compaction state, and tool metadata", async () => {
const onReasoningStream = vi.fn();
const onReasoningEnd = vi.fn();

View File

@@ -107,11 +107,7 @@ export class CodexAppServerEventProjector {
break;
case "item/autoApprovalReview/started":
case "item/autoApprovalReview/completed":
this.guardianReviewCount += 1;
this.emitAgentEvent({
stream: "codex_app_server.guardian",
data: { method: notification.method },
});
this.handleGuardianReviewNotification(notification.method, params);
break;
case "thread/tokenUsage/updated":
this.handleTokenUsage(params);
@@ -379,6 +375,27 @@ export class CodexAppServerEventProjector {
}
}
private handleGuardianReviewNotification(method: string, params: JsonObject): void {
this.guardianReviewCount += 1;
const review = isJsonObject(params.review) ? params.review : undefined;
const action = isJsonObject(params.action) ? params.action : undefined;
this.emitAgentEvent({
stream: "codex_app_server.guardian",
data: {
method,
phase: method.endsWith("/started") ? "started" : "completed",
reviewId: readString(params, "reviewId"),
targetItemId: readNullableString(params, "targetItemId"),
decisionSource: readString(params, "decisionSource"),
status: review ? readString(review, "status") : undefined,
riskLevel: review ? readString(review, "riskLevel") : undefined,
userAuthorization: review ? readString(review, "userAuthorization") : undefined,
rationale: review ? readNullableString(review, "rationale") : undefined,
actionType: action ? readString(action, "type") : undefined,
},
});
}
private async handleTurnCompleted(params: JsonObject): Promise<void> {
const turn = readTurn(params.turn);
if (!turn || turn.id !== this.turnId) {

View File

@@ -474,6 +474,7 @@ describe("runCodexAppServerAttempt", () => {
modelProvider: "openai",
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
developerInstructions: expect.stringContaining(CODEX_GPT5_BEHAVIOR_CONTRACT),
}),
},