mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:20:43 +00:00
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:
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -474,6 +474,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
developerInstructions: expect.stringContaining(CODEX_GPT5_BEHAVIOR_CONTRACT),
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user