Fix codex approve policy

This commit is contained in:
weishu
2026-03-16 13:00:09 +08:00
parent bfc9ad9f0c
commit 1f02bb7de1
12 changed files with 461 additions and 49 deletions
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const harness = vi.hoisted(() => ({
launches: [] as Array<Record<string, unknown>>
}));
vi.mock('./codexLocal', () => ({
codexLocal: async (opts: Record<string, unknown>) => {
harness.launches.push(opts);
}
}));
vi.mock('./utils/buildHapiMcpBridge', () => ({
buildHapiMcpBridge: async () => ({
server: {
url: 'http://localhost:0',
stop: () => {}
},
mcpServers: {}
})
}));
vi.mock('./utils/codexSessionScanner', () => ({
createCodexSessionScanner: async () => ({
cleanup: async () => {},
onNewSession: () => {}
})
}));
vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({
BaseLocalLauncher: class {
readonly control = {
requestExit: () => {}
};
constructor(private readonly opts: { launch: (signal: AbortSignal) => Promise<void> }) {}
async run(): Promise<'exit'> {
await this.opts.launch(new AbortController().signal);
return 'exit';
}
}
}));
import { codexLocalLauncher } from './codexLocalLauncher';
function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[]) {
return {
sessionId: null,
path: '/tmp/worktree',
startedBy: 'terminal' as const,
startingMode: 'local' as const,
codexArgs,
client: {
rpcHandlerManager: {}
},
getPermissionMode: () => permissionMode,
onSessionFound: () => {},
sendSessionEvent: () => {},
recordLocalLaunchFailure: () => {},
sendUserMessage: () => {},
sendCodexMessage: () => {},
queue: {}
};
}
describe('codexLocalLauncher', () => {
afterEach(() => {
harness.launches = [];
});
it('rebuilds approval and sandbox args from yolo mode', async () => {
const session = createSessionStub('yolo', [
'--sandbox',
'read-only',
'--ask-for-approval',
'untrusted',
'--model',
'o3',
'--full-auto'
]);
await codexLocalLauncher(session as never);
expect(harness.launches).toHaveLength(1);
expect(harness.launches[0]?.codexArgs).toEqual([
'--ask-for-approval',
'never',
'--sandbox',
'danger-full-access',
'--model',
'o3'
]);
});
it('preserves raw Codex approval flags in default mode', async () => {
const session = createSessionStub('default', [
'--ask-for-approval',
'on-request',
'--sandbox',
'workspace-write',
'--model',
'o3'
]);
await codexLocalLauncher(session as never);
expect(harness.launches).toHaveLength(1);
expect(harness.launches[0]?.codexArgs).toEqual([
'--ask-for-approval',
'on-request',
'--sandbox',
'workspace-write',
'--model',
'o3'
]);
});
it('keeps sandbox escalation available in safe-yolo mode', async () => {
const session = createSessionStub('safe-yolo', [
'--ask-for-approval',
'never',
'--sandbox',
'danger-full-access',
'--model',
'o3'
]);
await codexLocalLauncher(session as never);
expect(harness.launches).toHaveLength(1);
expect(harness.launches[0]?.codexArgs).toEqual([
'--ask-for-approval',
'on-failure',
'--sandbox',
'workspace-write',
'--model',
'o3'
]);
});
});
+13 -1
View File
@@ -4,11 +4,23 @@ import { CodexSession } from './session';
import { createCodexSessionScanner } from './utils/codexSessionScanner';
import { convertCodexEvent } from './utils/codexEventConverter';
import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
import { stripCodexCliOverrides } from './utils/codexCliOverrides';
import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
const resumeSessionId = session.sessionId;
let scanner: Awaited<ReturnType<typeof createCodexSessionScanner>> | null = null;
const permissionMode = session.getPermissionMode();
const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
? permissionMode
: null;
const codexArgs = managedPermissionMode
? [
...buildCodexPermissionModeCliArgs(managedPermissionMode),
...stripCodexCliOverrides(session.codexArgs)
]
: session.codexArgs;
// Start hapi hub for MCP bridge (same as remote mode)
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
@@ -32,7 +44,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
sessionId: resumeSessionId,
onSessionFound: handleSessionFound,
abort: abortSignal,
codexArgs: session.codexArgs,
codexArgs,
mcpServers
});
},
+6 -1
View File
@@ -176,7 +176,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
};
const permissionHandler = new CodexPermissionHandler(session.client, {
const permissionHandler = new CodexPermissionHandler(session.client, () => {
const mode = session.getPermissionMode();
return mode === 'default' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo'
? mode
: undefined;
}, {
onRequest: ({ id, toolName, input }) => {
const inputRecord = input && typeof input === 'object' ? input as Record<string, unknown> : {};
const message = typeof inputRecord.message === 'string' ? inputRecord.message : undefined;
@@ -33,6 +33,16 @@ describe('appServerConfig', () => {
});
expect(params.sandbox).toBe('danger-full-access');
expect(params.approvalPolicy).toBe('never');
});
it('keeps on-failure approvals for safe-yolo threads', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'safe-yolo' },
mcpServers
});
expect(params.sandbox).toBe('workspace-write');
expect(params.approvalPolicy).toBe('on-failure');
});
+4 -27
View File
@@ -9,41 +9,18 @@ import type {
ThreadStartParams,
TurnStartParams
} from '../appServerTypes';
import { resolveCodexPermissionModeConfig } from './permissionModeConfig';
function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy {
switch (mode.permissionMode) {
case 'default': return 'untrusted';
case 'read-only': return 'never';
case 'safe-yolo': return 'on-failure';
case 'yolo': return 'on-failure';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy;
}
function resolveSandbox(mode: EnhancedMode): SandboxMode {
switch (mode.permissionMode) {
case 'default': return 'workspace-write';
case 'read-only': return 'read-only';
case 'safe-yolo': return 'workspace-write';
case 'yolo': return 'danger-full-access';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
return resolveCodexPermissionModeConfig(mode.permissionMode).sandbox;
}
function resolveSandboxPolicy(mode: EnhancedMode): SandboxPolicy {
switch (mode.permissionMode) {
case 'default': return { type: 'workspaceWrite' };
case 'read-only': return { type: 'readOnly' };
case 'safe-yolo': return { type: 'workspaceWrite' };
case 'yolo': return { type: 'dangerFullAccess' };
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
return resolveCodexPermissionModeConfig(mode.permissionMode).sandboxPolicy;
}
function resolveSandboxPolicyOverride(value: CodexCliOverrides['sandbox'] | undefined): SandboxPolicy | undefined {
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseCodexCliOverrides } from './codexCliOverrides';
import { parseCodexCliOverrides, stripCodexCliOverrides } from './codexCliOverrides';
describe('parseCodexCliOverrides', () => {
it('parses sandbox and approval flags', () => {
@@ -50,4 +50,25 @@ describe('parseCodexCliOverrides', () => {
sandbox: 'read-only'
});
});
it('strips approval and sandbox overrides while keeping unrelated args', () => {
expect(stripCodexCliOverrides([
'--sandbox',
'read-only',
'--ask-for-approval=never',
'--model',
'o3',
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'--',
'--sandbox',
'danger-full-access'
])).toEqual([
'--model',
'o3',
'--',
'--sandbox',
'danger-full-access'
]);
});
});
+35
View File
@@ -86,3 +86,38 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides {
export function hasCodexCliOverrides(overrides?: CodexCliOverrides): boolean {
return Boolean(overrides?.sandbox || overrides?.approvalPolicy);
}
export function stripCodexCliOverrides(args?: string[]): string[] {
if (!args || args.length === 0) {
return [];
}
const filtered: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
filtered.push(...args.slice(i));
break;
}
if (
arg === '--full-auto'
|| arg === '--yolo'
|| arg === '--dangerously-bypass-approvals-and-sandbox'
|| arg.startsWith('--sandbox=')
|| arg.startsWith('--ask-for-approval=')
) {
continue;
}
if (arg === '-s' || arg === '--sandbox' || arg === '-a' || arg === '--ask-for-approval') {
i += 1;
continue;
}
filtered.push(arg);
}
return filtered;
}
@@ -32,6 +32,18 @@ describe('buildCodexStartConfig', () => {
});
expect(config.sandbox).toBe('danger-full-access');
expect(config['approval-policy']).toBe('never');
});
it('keeps on-failure approvals for safe-yolo', () => {
const config = buildCodexStartConfig({
message: 'hello',
mode: { permissionMode: 'safe-yolo' },
first: false,
mcpServers
});
expect(config.sandbox).toBe('workspace-write');
expect(config['approval-policy']).toBe('on-failure');
});
+3 -18
View File
@@ -2,29 +2,14 @@ import type { CodexSessionConfig } from '../types';
import type { EnhancedMode } from '../loop';
import type { CodexCliOverrides } from './codexCliOverrides';
import { codexSystemPrompt } from './systemPrompt';
import { resolveCodexPermissionModeConfig } from './permissionModeConfig';
function resolveApprovalPolicy(mode: EnhancedMode): CodexSessionConfig['approval-policy'] {
switch (mode.permissionMode) {
case 'default': return 'untrusted';
case 'read-only': return 'never';
case 'safe-yolo': return 'on-failure';
case 'yolo': return 'on-failure';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy;
}
function resolveSandbox(mode: EnhancedMode): CodexSessionConfig['sandbox'] {
switch (mode.permissionMode) {
case 'default': return 'workspace-write';
case 'read-only': return 'read-only';
case 'safe-yolo': return 'workspace-write';
case 'yolo': return 'danger-full-access';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
return resolveCodexPermissionModeConfig(mode.permissionMode).sandbox;
}
export function buildCodexStartConfig(args: {
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import type { ApiSessionClient } from '@/api/apiSession';
import { CodexPermissionHandler } from './permissionHandler';
type FakeAgentState = {
requests: Record<string, unknown>;
completedRequests: Record<string, unknown>;
};
function createHarness(mode: 'default' | 'read-only' | 'safe-yolo' | 'yolo') {
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
};
const rpcHandlers = new Map<string, (params: unknown) => Promise<unknown> | unknown>();
const session = {
rpcHandlerManager: {
registerHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown) {
rpcHandlers.set(method, handler);
}
},
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
agentState = handler(agentState);
}
} as unknown as ApiSessionClient;
const handler = new CodexPermissionHandler(session, () => mode);
return {
handler,
rpcHandlers,
getAgentState: () => agentState
};
}
describe('CodexPermissionHandler', () => {
it('auto-approves yolo requests for the session', async () => {
const { handler, getAgentState } = createHarness('yolo');
await expect(handler.handleToolCall('perm-1', 'CodexPatch', { grantRoot: '/tmp' })).resolves.toEqual({
decision: 'approved_for_session'
});
expect(getAgentState().requests).toEqual({});
expect(getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'CodexPatch',
status: 'approved',
decision: 'approved_for_session'
}
});
});
it('auto-approves safe-yolo requests once', async () => {
const { handler, getAgentState } = createHarness('safe-yolo');
await expect(handler.handleToolCall('perm-1', 'CodexBash', { command: 'pwd' })).resolves.toEqual({
decision: 'approved'
});
expect(getAgentState().requests).toEqual({});
expect(getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'CodexBash',
status: 'approved',
decision: 'approved'
}
});
});
it('keeps default mode requests pending until a permission RPC arrives', async () => {
const { handler, rpcHandlers, getAgentState } = createHarness('default');
const resultPromise = handler.handleToolCall('perm-1', 'CodexPatch', { grantRoot: '/tmp' });
expect(getAgentState().requests).toMatchObject({
'perm-1': {
tool: 'CodexPatch'
}
});
const permissionRpc = rpcHandlers.get('permission');
expect(permissionRpc).toBeTypeOf('function');
await permissionRpc?.({ id: 'perm-1', approved: true, decision: 'approved' });
await expect(resultPromise).resolves.toEqual({
decision: 'approved',
reason: undefined
});
expect(getAgentState().requests).toEqual({});
expect(getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'CodexPatch',
status: 'approved',
decision: 'approved'
}
});
});
it('auto-approves read-only non-write tools but not patches', async () => {
const { handler, getAgentState } = createHarness('read-only');
await expect(handler.handleToolCall('read-1', 'Read', { file: 'README.md' })).resolves.toEqual({
decision: 'approved'
});
const patchPromise = handler.handleToolCall('patch-1', 'CodexPatch', { grantRoot: '/tmp' });
expect(getAgentState().requests).toMatchObject({
'patch-1': {
tool: 'CodexPatch'
}
});
handler.reset();
await expect(patchPromise).rejects.toThrow('Session reset');
});
});
+50 -1
View File
@@ -7,8 +7,10 @@
import { logger } from "@/ui/logger";
import { ApiSessionClient } from "@/api/apiSession";
import type { CodexPermissionMode } from "@hapi/protocol/types";
import {
BasePermissionHandler,
type AutoApprovalDecision,
type PendingPermissionRequest,
type PermissionCompletion
} from "@/modules/common/permission/BasePermissionHandler";
@@ -38,7 +40,11 @@ type CodexPermissionHandlerOptions = {
};
export class CodexPermissionHandler extends BasePermissionHandler<PermissionResponse, PermissionResult> {
constructor(session: ApiSessionClient, private readonly options?: CodexPermissionHandlerOptions) {
constructor(
session: ApiSessionClient,
private readonly getPermissionMode: () => CodexPermissionMode | undefined,
private readonly options?: CodexPermissionHandlerOptions
) {
super(session);
}
@@ -46,6 +52,43 @@ export class CodexPermissionHandler extends BasePermissionHandler<PermissionResp
this.options?.onRequest?.({ id, toolName, input });
}
private completeAutoApproval(
id: string,
toolName: string,
input: unknown,
decision: AutoApprovalDecision
): PermissionResult {
const timestamp = Date.now();
this.options?.onRequest?.({ id, toolName, input });
this.options?.onComplete?.({
id,
toolName,
input,
approved: true,
decision
});
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[id]: {
tool: toolName,
arguments: input,
createdAt: timestamp,
completedAt: timestamp,
status: 'approved',
decision
}
}
}));
logger.debug(`[Codex] Auto-approved ${toolName} (${id}) with decision=${decision}`);
return { decision };
}
/**
* Handle a tool permission request
* @param toolCallId - The unique ID of the tool call
@@ -58,6 +101,12 @@ export class CodexPermissionHandler extends BasePermissionHandler<PermissionResp
toolName: string,
input: unknown
): Promise<PermissionResult> {
const mode = this.getPermissionMode() ?? 'default';
const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, toolCallId);
if (autoDecision) {
return Promise.resolve(this.completeAutoApproval(toolCallId, toolName, input, autoDecision));
}
return new Promise<PermissionResult>((resolve, reject) => {
// Store the pending request
this.addPendingRequest(toolCallId, toolName, input, { resolve, reject });
@@ -0,0 +1,46 @@
import type { CodexPermissionMode } from '@hapi/protocol/types';
import type { ApprovalPolicy, SandboxMode, SandboxPolicy } from '../appServerTypes';
export type CodexPermissionModeConfig = {
approvalPolicy: ApprovalPolicy;
sandbox: SandboxMode;
sandboxPolicy: SandboxPolicy;
};
export function resolveCodexPermissionModeConfig(mode: CodexPermissionMode): CodexPermissionModeConfig {
switch (mode) {
case 'default':
return {
approvalPolicy: 'untrusted',
sandbox: 'workspace-write',
sandboxPolicy: { type: 'workspaceWrite' }
};
case 'read-only':
return {
approvalPolicy: 'never',
sandbox: 'read-only',
sandboxPolicy: { type: 'readOnly' }
};
case 'safe-yolo':
return {
// Keep escalation available when the workspace-write sandbox blocks a command.
approvalPolicy: 'on-failure',
sandbox: 'workspace-write',
sandboxPolicy: { type: 'workspaceWrite' }
};
case 'yolo':
return {
approvalPolicy: 'never',
sandbox: 'danger-full-access',
sandboxPolicy: { type: 'dangerFullAccess' }
};
}
const unexpectedMode: never = mode;
throw new Error(`Unknown permission mode: ${unexpectedMode}`);
}
export function buildCodexPermissionModeCliArgs(mode: Exclude<CodexPermissionMode, 'default'>): string[] {
const config = resolveCodexPermissionModeConfig(mode);
return ['--ask-for-approval', config.approvalPolicy, '--sandbox', config.sandbox];
}