diff --git a/readme-dev.md b/readme-dev.md index b5198d262..ba57ceb7f 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -27,6 +27,15 @@ launch variable is removed from the Codex child environment after it is parsed. Malformed or incomplete mappings fail startup instead of falling back to legacy sandbox behavior. +With external profiles, Full access accepts native tool approvals once for HTTP +MCP servers injected by ACP during the current prompt; changing mode revokes this. +All granular approval categories, including server-origin elicitations, stay +disabled. Other modes retain their normal approval options. Native MCP settings, +explicit per-tool approval rules, and stdio servers receive no automatic approval. +Child turns and already-loaded resumes are excluded because their effective +policy or configuration cannot be verified; cold resumes apply the supplied config. +Native hooks report `permission_mode: default` for this granular policy. + ### Quick start #### Develop on Windows? diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 02ea40c03..e98746b6c 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -69,7 +69,7 @@ import { } from "./AgentFileChangeReport"; import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; -import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; +import type {PreparedSessionConfig, SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; import { permissionProfileForMode, @@ -543,8 +543,10 @@ export class CodexAcpClient { const initialAgentMode = AgentMode.getInitialAgentMode(); await this.refreshSkills(request.cwd, additionalDirectories); + const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); + await this.restrictResumedHttpMcpServers(request.sessionId, prepared); const response = await this.codexClient.threadResume({ - config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), + config: prepared.config, cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, @@ -561,6 +563,7 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers, } } @@ -584,8 +587,10 @@ export class CodexAcpClient { const initialAgentMode = AgentMode.getInitialAgentMode(); await this.refreshSkills(request.cwd, additionalDirectories); + const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); + await this.restrictResumedHttpMcpServers(request.sessionId, prepared); const response = await this.codexClient.threadResume({ - config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), + config: prepared.config, cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, @@ -607,6 +612,7 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, additionalDirectories, + fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers, }; } @@ -622,8 +628,9 @@ export class CodexAcpClient { const initialAgentMode = AgentMode.getInitialAgentMode(); await this.refreshSkills(request.cwd, additionalDirectories); + const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers); const response = await this.codexClient.threadStart({ - config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers), + config: prepared.config, modelProvider: this.getModelProvider(), cwd: request.cwd, ...this.permissionProfileSelection(initialAgentMode, request.cwd, additionalDirectories), @@ -642,6 +649,7 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers, }; } @@ -662,12 +670,29 @@ export class CodexAcpClient { const config = this.permissionProfileConfig; if (!config) return {}; return { - approvalPolicy: agentMode.approvalPolicy, + approvalPolicy: this.approvalPolicyForMode(agentMode), permissions: permissionProfileForMode(config, agentMode.id), runtimeWorkspaceRoots: sessionRoots(cwd, additionalDirectories), }; } + isProtectedFullAccess(agentMode: AgentMode): boolean { + return this.permissionProfileConfig !== undefined && agentMode.kind === "full_access"; + } + + private approvalPolicyForMode(agentMode: AgentMode): AgentMode["approvalPolicy"] { + if (!this.isProtectedFullAccess(agentMode)) return agentMode.approvalPolicy; + // Codex 0.153.3 routes tool approvals separately; false blocks server-origin elicitations, not native tool approvals. + return {granular: {sandbox_approval: false, rules: false, skill_approval: false, request_permissions: false, mcp_elicitations: false}}; + } + + private async restrictResumedHttpMcpServers(sessionId: string, prepared: PreparedSessionConfig): Promise { + if (prepared.fullAccessHttpMcpServers.length === 0) return; + const loaded = await this.codexClient.threadLoadedList({}); + // A loaded thread may ignore resume config, so its requested transport is not proof of its actual transport. + if (loaded.data.includes(sessionId) || loaded.nextCursor !== null) prepared.fullAccessHttpMcpServers = []; + } + async deleteSession(sessionId: string): Promise { await this.codexClient.threadArchive({threadId: sessionId}); } @@ -759,7 +784,7 @@ export class CodexAcpClient { projectPath: string, additionalDirectories: string[], mcpServers: Array, - ): Promise { + ): Promise { const sessionRoots = [projectPath, ...additionalDirectories]; const activeProvider = this.gatewayConfig ? { @@ -782,7 +807,7 @@ export class CodexAcpClient { }; const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories); if (mcpServers.length === 0) { - return configWithWorkspaceRoots; + return {config: configWithWorkspaceRoots, fullAccessHttpMcpServers: []}; } const requestedServers = mcpServers.map(mcp => ({ @@ -790,18 +815,27 @@ export class CodexAcpClient { server: mcp, })); let serversToConfigure = requestedServers; + const existingNames = shouldDeduplicateMcpConflicts() || this.permissionProfileConfig + ? await this.getConfigMcpServerNames(projectPath) + : new Set(); if (shouldDeduplicateMcpConflicts()) { // Prevents Codex from deep-merging incompatible field types, such as url and stdio schemas. - const existingNames = await this.getConfigMcpServerNames(projectPath); serversToConfigure = requestedServers.filter(mcp => !existingNames.has(mcp.name)); } if (serversToConfigure.length === 0) { - return configWithWorkspaceRoots; + return {config: configWithWorkspaceRoots, fullAccessHttpMcpServers: []}; } + const configuredServers = Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])); + const inheritedServers = isJsonObject(this.config["mcp_servers"]) ? this.config["mcp_servers"] : {}; return { - ...configWithWorkspaceRoots, - "mcp_servers": Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])), + config: {...configWithWorkspaceRoots, mcp_servers: configuredServers}, + fullAccessHttpMcpServers: this.permissionProfileConfig + ? Object.entries(configuredServers) + .filter(([name, server]) => typeof server["url"] === "string" + && !existingNames.has(name) && !Object.hasOwn(inheritedServers, name)) + .map(([name]) => name) + : [], }; } @@ -984,7 +1018,7 @@ export class CodexAcpClient { return await this.codexClient.runTurn({ threadId: request.sessionId, input: input, - approvalPolicy: agentMode.approvalPolicy, + approvalPolicy: this.approvalPolicyForMode(agentMode), approvalsReviewer: agentMode.approvalsReviewer, ...sandboxSelection, summary: disableSummary ? "none" : "auto", @@ -1114,7 +1148,7 @@ export class CodexAcpClient { if (!config) return; await this.codexClient.threadSettingsUpdate({ threadId: sessionId, - approvalPolicy: agentMode.approvalPolicy, + approvalPolicy: this.approvalPolicyForMode(agentMode), permissions: permissionProfileForMode(config, agentMode.id), }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index c91862584..3875cb5e4 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -159,6 +159,8 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + permissionModeRevision?: number; + fullAccessHttpMcpServers?: string[]; collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; @@ -672,6 +674,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + fullAccessHttpMcpServers: sessionMetadata.fullAccessHttpMcpServers ?? [], collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, @@ -1055,12 +1058,13 @@ export class CodexAcpServer { for (const session of this.sessions.values()) { session.asyncTasks.setAppServer(replacement.appServerClient); try { - await replacement.resumeSession({ + const metadata = await replacement.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, additionalDirectories: session.additionalDirectories, mcpServers: session.mcpServers ?? [], }); + session.fullAccessHttpMcpServers = metadata.fullAccessHttpMcpServers ?? []; session.authProvider = replacement.getModelProvider(); session.asyncTasks.refresh(); logger.log("Resumed session after provider restart", {sessionId: session.sessionId}); @@ -1388,6 +1392,7 @@ export class CodexAcpServer { if (!newMode) { throw RequestError.invalidParams(); } + sessionState.permissionModeRevision = (sessionState.permissionModeRevision ?? 0) + 1; await this.codexAcpClient.setAgentMode( sessionState.sessionId, newMode, @@ -1933,6 +1938,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + fullAccessHttpMcpServers: sessionMetadata.fullAccessHttpMcpServers ?? [], collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, @@ -2807,16 +2813,30 @@ export class CodexAcpServer { eventHandler = promptEventHandler; const permissionLifecycle = this.permissionLifecycleContext(sessionState); const permissionContext = permissionLifecycle.beginPrompt(); + let promptAgentMode: AgentMode | undefined; + let permissionModeRevision: number | undefined; + const httpServers = new Set(sessionState.fullAccessHttpMcpServers); + const noHttpServers = new Set(); + let mcpApprovalTurnId: string | null = null; + const isProtectedFullAccess = () => this.codexAcpClient.isProtectedFullAccess(promptAgentMode ?? sessionState.agentMode); const approvalHandler = new CodexApprovalHandler( this.connection, permissionContext, activePrompt.signal, + isProtectedFullAccess, ); const elicitationHandler = new CodexElicitationHandler( this.connection, permissionContext, this.clientCapabilities, activePrompt.signal, + request => { + if (!isProtectedFullAccess()) return undefined; + return mcpApprovalTurnId !== null && request.turnId === mcpApprovalTurnId + && sessionState.agentMode.kind === "full_access" + && sessionState.permissionModeRevision === permissionModeRevision + ? httpServers : noHttpServers; + }, ); const observeInteraction = async (event: ServerNotification): Promise => { permissionContext.handleNotification(event); @@ -2954,6 +2974,8 @@ export class CodexAcpServer { throw RequestError.invalidRequest("The current model does not support image input"); } const agentMode = sessionState.agentMode; + promptAgentMode = agentMode; + permissionModeRevision = sessionState.permissionModeRevision; const serviceTier = resolveFastServiceTier( sessionState.fastModeEnabled, sessionState.currentModelSupportsFast, @@ -2970,6 +2992,7 @@ export class CodexAcpServer { sessionState.cwd, sessionState.additionalDirectories, (turnId) => { + mcpApprovalTurnId = turnId; const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { @@ -2980,7 +3003,8 @@ export class CodexAcpServer { pendingTurnStart?.resolve(turnId); onTurnStarted?.(); }, - () => this.promptShouldStop(params.sessionId, activePrompt), + () => this.promptShouldStop(params.sessionId, activePrompt) + || sessionState.permissionModeRevision !== permissionModeRevision, )); void sendPromptPromise.catch((err) => { if (this.activePrompts.get(params.sessionId) !== activePrompt) { @@ -3071,6 +3095,7 @@ export class CodexAcpServer { sessionState.cwd, sessionState.additionalDirectories, (turnId) => { + mcpApprovalTurnId = turnId; const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { @@ -3083,7 +3108,8 @@ export class CodexAcpServer { recoverableSessionFailure = sessionState.sessionFailure; promptNotificationsActive = true; }, - () => this.promptShouldStop(params.sessionId, activePrompt), + () => this.promptShouldStop(params.sessionId, activePrompt) + || sessionState.permissionModeRevision !== permissionModeRevision, ), ); void implementationPromise.catch((err) => { diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index abbbb70be..88015ebfa 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -141,27 +141,15 @@ export class CodexElicitationHandler implements ElicitationHandler { private readonly permissionContext: PermissionPromptContext; private readonly clientCapabilities: acp.ClientCapabilities | null; private readonly cancellationSignal: AbortSignal | undefined; - // In Rust, the MCP elicitation handler receives ElicitationRequestEvent directly from the MCP - // protocol layer, where id is set to "mcp_tool_call_approval_" — the call ID is extracted - // by stripping that prefix. - // - // In TypeScript, Codex speaks the app-server JSON-RPC protocol (v2), where - // McpServerElicitationRequestParams omits elicitationId for form mode, so the MCP-level ID never - // reaches the client. - // - // Workaround: before requesting approval, Codex emits an item/started notification with an - // mcpToolCall item carrying the call id and server name. The shared permission lifecycle stores - // (threadId, serverName) → callId so this request can correlate to the rendered tool call item. - // - // The app-server handler exposes URL elicitationId, while serverRequest/resolved only exposes - // threadId here, so accepted URL elicitations are completed at thread scope. + // App-server omits form elicitation IDs, so item/started correlates MCP approvals to tool calls. private readonly pendingUrlElicitations = new Map>(); constructor( connection: AcpClientConnection, permissionContext: PermissionPromptContext, clientCapabilities: acp.ClientCapabilities | null = null, - cancellationSignal?: AbortSignal + cancellationSignal?: AbortSignal, + private readonly fullAccessHttpServers?: (params: Pick) => ReadonlySet | undefined, ) { this.connection = connection; this.permissionContext = permissionContext; @@ -184,6 +172,16 @@ export class CodexElicitationHandler implements ElicitationHandler { ): Promise { try { const context = this.createMcpElicitationContext(params); + const httpServers = this.fullAccessHttpServers?.(params); + if (httpServers !== undefined) { + const accepted = !this.cancellationSignal?.aborted + && params.mode === "form" + && context.isToolApproval + && context.persistOptions.has("session") + && httpServers.has(params.serverName); + await this.publishAcceptedMcpToolApproval(params.threadId, context, accepted); + return {action: accepted ? "accept" : "cancel", content: accepted ? {} : null, _meta: null}; + } if (this.shouldUseAcpElicitation(params)) { const response = await this.connection.request( acp.methods.client.elicitation.create, @@ -231,6 +229,12 @@ export class CodexElicitationHandler implements ElicitationHandler { } async handleUserInput(params: ToolRequestUserInputParams): Promise { + // Codex's skill dependency installer checks only `never`, so retain its refusal under the granular policy. + if (this.fullAccessHttpServers?.(params) !== undefined + && params.itemId === `mcp-deps-${params.turnId}` + && params.questions.some(question => question.id === "skill_mcp_dependency_install")) { + return {answers: {}}; + } if (!clientSupportsFormElicitation(this.clientCapabilities)) { return { answers: {} }; } diff --git a/src/SessionFork.ts b/src/SessionFork.ts index b52b7caea..fb8e8a91a 100644 --- a/src/SessionFork.ts +++ b/src/SessionFork.ts @@ -4,8 +4,8 @@ import {RequestError} from "@agentclientprotocol/sdk"; import type {CodexAppServerClient} from "./CodexAppServerClient"; import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; -import type {Model, ThreadForkParams} from "./app-server/v2"; -import type {SessionMetadata} from "./SessionMetadata"; +import type {Model} from "./app-server/v2"; +import type {PreparedSessionConfig, SessionMetadata} from "./SessionMetadata"; export type SessionForkDependencies = { codexClient: CodexAppServerClient; @@ -14,7 +14,7 @@ export type SessionForkDependencies = { cwd: string, additionalDirectories: string[], mcpServers: acp.McpServer[], - ): Promise>; + ): Promise; getResumeModelProvider(): Promise; fetchAvailableModels(): Promise; createCurrentModelId(models: Model[], model: string, reasoningEffort: string | null): string; @@ -28,12 +28,9 @@ export async function forkSession( ): Promise { await dependencies.refreshSkills(request.cwd, additionalDirectories); const lastTurnId = await resolveForkTurnId(request, dependencies.codexClient); + const prepared = await dependencies.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); const response = await dependencies.codexClient.threadFork({ - config: await dependencies.createSessionConfig( - request.cwd, - additionalDirectories, - request.mcpServers ?? [], - ), + config: prepared.config, cwd: request.cwd, ...(lastTurnId !== undefined && {lastTurnId}), modelProvider: await dependencies.getResumeModelProvider(), @@ -50,6 +47,7 @@ export async function forkSession( modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers, }; } diff --git a/src/SessionMetadata.ts b/src/SessionMetadata.ts index 505620584..ac3969496 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -1,6 +1,12 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model, Thread} from "./app-server/v2"; +import type {JsonValue} from "./app-server/serde_json/JsonValue"; + +export type PreparedSessionConfig = { + config: {[key: string]: JsonValue | undefined}; + fullAccessHttpMcpServers: string[]; +}; export type SessionMetadata = { sessionId: string, @@ -10,6 +16,7 @@ export type SessionMetadata = { modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], + fullAccessHttpMcpServers?: string[], } export type SessionMetadataWithThread = SessionMetadata & { diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index f96dba504..cf0ef2e6b 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1559,6 +1559,33 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy).not.toHaveBeenCalled(); }); + it('cancels a pending Full access prompt after a mode downgrade', async () => { + const {mockFixture, sessionState, turnStartSpy} = setupPromptTestSession({ + cwd: "/workspace", + agentMode: AgentMode.AgentFullAccess, + }, TEST_PERMISSION_PROFILE_CONFIG); + const skillsRefresh = deferred<{data: []}>(); + const listSkillsSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "listSkills") + .mockReturnValue(skillsRefresh.promise); + const settingsSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadSettingsUpdate") + .mockResolvedValue(undefined); + const agent = mockFixture.getCodexAcpAgent(); + // @ts-expect-error - registering local session state for the ACP mode change path + agent.sessions.set(sessionState.sessionId, sessionState); + + const promptPromise = agent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "Update the project"}], + }); + await vi.waitFor(() => expect(listSkillsSpy).toHaveBeenCalled()); + await agent.setSessionMode({sessionId: "session-id", modeId: AgentMode.ReadOnly.id}); + skillsRefresh.resolve({data: []}); + + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + expect(settingsSpy).toHaveBeenCalledWith(expect.objectContaining({approvalPolicy: "on-request"})); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + it('should send attachments as prompt items', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); diff --git a/src/__tests__/PermissionLifecycleContext.test.ts b/src/__tests__/PermissionLifecycleContext.test.ts index ad90788d6..a04192cf6 100644 --- a/src/__tests__/PermissionLifecycleContext.test.ts +++ b/src/__tests__/PermissionLifecycleContext.test.ts @@ -1,9 +1,11 @@ import {describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; import type {SessionState} from "../CodexAcpServer"; import {CodexElicitationHandler} from "../CodexElicitationHandler"; import type {AcpClientConnection} from "../ACPSessionConnection"; import type {ServerNotification} from "../app-server"; import {PermissionLifecycleContext} from "../permissions/lifecycle"; +import {CodexApprovalHandler} from "../permissions/CodexApprovalHandler"; function sessionState(): SessionState { return { @@ -189,4 +191,80 @@ describe("PermissionLifecycleContext", () => { "elicitation:session:server:1", ]); }); + + it("autoapproves only eligible HTTP auto policy and restores client approval on a mode change", async () => { + let httpServers: ReadonlySet | undefined = new Set(["server"]); + const request = vi.fn().mockResolvedValue({outcome: {outcome: "selected", optionId: "allow_once"}}); + const notify = vi.fn(); + const cancellation = new AbortController(); + const prompt = new PermissionLifecycleContext(sessionState()).beginPrompt(); + const handler = new CodexElicitationHandler( + {request, notify} as unknown as AcpClientConnection, + prompt, null, cancellation.signal, () => httpServers, + ); + const approval = { + threadId: "thread", turnId: "turn-1", serverName: "server", mode: "form" as const, + _meta: {codex_approval_kind: "mcp_tool_call", persist: "session"}, + message: "Allow?", requestedSchema: {type: "object" as const, properties: {}}, + }; + prompt.handleNotification(mcpStarted("call", "turn-1")); + expect(await handler.handleElicitation(approval)).toEqual({action: "accept", content: {}, _meta: null}); + expect(notify).toHaveBeenCalledWith(acp.methods.client.session.update, { + sessionId: "thread", update: {sessionUpdate: "tool_call_update", toolCallId: "call", status: "in_progress"}, + }); + for (const rejected of [ + {...approval, serverName: "stdio"}, + {...approval, _meta: {codex_approval_kind: "mcp_tool_call"}}, + {...approval, _meta: null}, + {...approval, requestedSchema: {type: "object" as const, properties: {value: {type: "string" as const}}}}, + ]) { + expect(await handler.handleElicitation(rejected)).toEqual({action: "cancel", content: null, _meta: null}); + } + expect(request).not.toHaveBeenCalled(); + httpServers = undefined; + expect(await handler.handleElicitation(approval)).toEqual({action: "accept", content: null, _meta: null}); + expect(request).toHaveBeenCalledTimes(1); + httpServers = new Set(["server"]); + expect(await handler.handleElicitation(approval)).toEqual({action: "accept", content: {}, _meta: null}); + cancellation.abort(); + expect(await handler.handleElicitation(approval)).toEqual({action: "cancel", content: null, _meta: null}); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("keeps Full access permission refusals without blocking ordinary questions", async () => { + let fullAccess = true; + const request = vi.fn().mockResolvedValue({action: "accept", content: {choice: "Proceed"}}); + const connection = {request} as unknown as AcpClientConnection; + const prompt = new PermissionLifecycleContext(sessionState()).beginPrompt(); + const elicitation = new CodexElicitationHandler( + connection, prompt, {elicitation: {form: {}}}, undefined, + () => fullAccess ? new Set(["server"]) : undefined, + ); + const approvals = new CodexApprovalHandler(connection, prompt, undefined, () => fullAccess); + const input = { + threadId: "thread", turnId: "turn-1", itemId: "mcp-deps-turn-1", isBlocking: true, autoResolutionMs: null, + questions: [{id: "skill_mcp_dependency_install", header: "Install", question: "Install MCP dependency?", + isOther: false, isSecret: false, options: null}], + }; + expect(await elicitation.handleUserInput(input)).toEqual({answers: {}}); + const network = { + kind: "command" as const, threadId: "thread", turnId: "turn-1", itemId: "network", startedAtMs: 0, + environmentId: null, networkApprovalContext: {host: "example.test", protocol: "https" as const}, + }; + expect(await approvals.handleCommandExecution(network)).toEqual({decision: "cancel"}); + expect(await approvals.handlePermissionsRequest({ + threadId: "thread", turnId: "turn-1", itemId: "permissions", startedAtMs: 0, + environmentId: null, cwd: "/workspace", reason: null, permissions: {network: {enabled: true}, fileSystem: null}, + })).toEqual({permissions: {}, scope: "turn", strictAutoReview: false}); + expect(request).not.toHaveBeenCalled(); + + for (fullAccess of [true, false]) { + expect(await elicitation.handleUserInput({ + ...input, itemId: "question", questions: [{...input.questions[0]!, id: "choice"}], + })).toEqual({answers: {choice: {answers: ["Proceed"]}}}); + } + request.mockResolvedValueOnce({outcome: {outcome: "selected", optionId: "allow_once"}}); + expect(await approvals.handleCommandExecution(network)).toEqual({decision: "accept"}); + expect(request).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/permissions/CodexApprovalHandler.ts b/src/permissions/CodexApprovalHandler.ts index 38a639ee3..be8107072 100644 --- a/src/permissions/CodexApprovalHandler.ts +++ b/src/permissions/CodexApprovalHandler.ts @@ -35,6 +35,7 @@ export class CodexApprovalHandler implements ApprovalHandler { private readonly connection: AcpClientConnection, private readonly permissionContext: PermissionPromptContext, private readonly cancellationSignal?: AbortSignal, + private readonly denyRequests?: () => boolean, ) {} async handleCommandExecution( @@ -103,6 +104,7 @@ export class CodexApprovalHandler implements ApprovalHandler { } private requestPermission(request: acp.RequestPermissionRequest): Promise { + if (this.denyRequests?.()) return Promise.resolve({outcome: {outcome: "cancelled"}}); return this.connection.request( acp.methods.client.session.requestPermission, request,