Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- ChatGPT, API key, and client-provided custom gateway authentication.
- Model, reasoning effort, fast mode, approval, and sandbox mode configuration.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- Shell command, file change, permission request, MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- Subagent launches as standard ACP tool calls, with Codex thread identity and activity details in namespaced `_meta.codex.subagent` metadata.
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md).
- Client-provided MCP servers over command-based stdio config and HTTP transport.
Expand Down
180 changes: 180 additions & 0 deletions docs/permission-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Permission presentation extension

For a user-facing summary of behavior changes, see
[`permission-changes.ru.md`](permission-changes.ru.md).

This document defines the provider-neutral permission presentation implemented by `codex-acp`. Permission decisions use the standard ACP `session/request_permission` method. The optional `_meta.permission` extension adds display text only; it never changes which actions a client may approve.

## Protocol contract

Every permission request contains:

- a `toolCall` describing the action that needs approval;
- an ordered `options` array containing every decision the user may select;
- optional request-level and option-level `_meta.permission` presentation data.

Clients make a decision by returning one of the advertised `optionId` values. They must not derive a decision from the option label, `kind`, or metadata. `codex-acp` keeps the exact Codex decision associated with each option and returns that original value to Codex.

```json
{
"sessionId": "session-1",
"toolCall": {
"toolCallId": "command-7",
"kind": "execute",
"status": "pending",
"title": "Run command",
"rawInput": {
"command": "npm test",
"cwd": "/workspace"
}
},
"options": [
{
"optionId": "allow_once",
"name": "Yes, proceed",
"kind": "allow_once"
},
{
"optionId": "cancel",
"name": "No, and tell Codex what to do differently",
"kind": "reject_once"
}
],
"_meta": {
"permission": {
"version": 1,
"title": "Run command?",
"description": "The test suite needs to run outside the current sandbox."
}
}
}
```

The standard ACP fields are the compatibility contract. A client that ignores `_meta.permission` can still render the action, present every option, and return a correct decision.

## Presentation metadata

Request-level metadata has this shape:

```json
{
"_meta": {
"permission": {
"version": 1,
"title": "Allow network access?",
"description": "Download the requested dependency."
}
}
}
```

`version` and `title` are required. `description` is optional and contains the non-blank reason supplied by Codex. Action payloads are not copied into metadata.

An individual option may provide a description:

```json
{
"optionId": "allow_session",
"name": "Allow for this session",
"kind": "allow_always",
"_meta": {
"permission": {
"version": 1,
"description": "Run the tool and remember this choice for this session."
}
}
}
```

No capability negotiation is required. The metadata is optional, additive, and safe for clients to ignore.

## Action presentation

The `toolCall` remains the authoritative description of the action:

- `rawInput` contains structured command, working-directory, server, URL, or permission-profile data.
- `locations` contains affected filesystem paths when Codex provides them.
- `content` carries details that do not fit a location, such as a network host, filesystem glob, special Codex scope, or MCP message.
- `title`, `kind`, and `status` provide the standard ACP summary.

Command approvals use `kind: execute`. File changes use `kind: edit`. Additional sandbox permissions use `kind: other`. URL authorization fallback uses `kind: fetch`.

For file changes, locations come from the correlated Codex `fileChange` item. `grantRoot` is not presented as though every file below it will be modified.

## Command and network decisions

When Codex sends `availableDecisions`, that ordered list is authoritative. Older Codex versions that omit it use the native Codex fallback decision set.

| Codex decision | ACP option kind | Meaning |
| --- | --- | --- |
| `accept` | `allow_once` | Approve this execution once. |
| `acceptForSession` | `allow_always` | Approve the command, host, or requested permissions for this session. |
| `acceptWithExecpolicyAmendment` | `allow_always` | Approve and install the exact proposed command-prefix rule. |
| network amendment with `allow` | `allow_always` | Approve and install the exact proposed allow rule. |
| network amendment with `deny` | `reject_always` | Reject and install the exact proposed deny rule. |
| `decline` | `reject_once` | Reject this execution and continue the turn. |
| `cancel` | `reject_once` | Reject this execution and abort the pending operation. |

Exec-policy and network amendments are returned as the exact structured values supplied by Codex. An amendment is rejected if it does not match the corresponding proposal. An exec-policy option whose rendered prefix contains a line break is not shown, matching the native Codex UI.

Unknown, malformed, empty, or internally inconsistent authoritative decision sets fail closed with `cancel`; the adapter does not invent replacement choices.

## File changes

File-change approvals expose the native Codex choices:

| ACP option | Kind | Codex decision |
| --- | --- | --- |
| `Yes, proceed` | `allow_once` | `accept` |
| `Yes, and don't ask again for these files` | `allow_always` | `acceptForSession` |
| `No, and tell Codex what to do differently` | `reject_once` | `cancel` |

Although the protocol decision enum also contains `decline`, the native Codex file-change prompt does not currently advertise it.

## Additional sandbox permissions

Codex may request a structured network and filesystem permission profile. `codex-acp` returns only permissions from that requested profile; Codex intersects the response with the original request before applying it.

| User choice | Scope | `strictAutoReview` |
| --- | --- | --- |
| Grant for this turn | `turn` | `false` |
| Grant for this turn with strict auto review | `turn` | `true` |
| Grant for this session | `session` | `false` |
| Continue without permissions | `turn` | `false` |

Strict auto review is intentionally turn-scoped. It causes subsequent actions in that turn to pass through Codex review even when ordinary sandbox policy would allow them. It is never combined with a session-scoped grant.

Cancellation, an unknown option, a stale turn, or a missing handler returns an empty permission profile with turn scope and `strictAutoReview: false`.

## MCP elicitation approvals

Message-only MCP elicitations use `session/request_permission` so clients receive the same decision matrix as the native Codex UI. Codex advertises durable choices through request `_meta.persist`; `codex-acp` never creates a persistence scope that the server did not offer.

| Advertised condition | ACP option | MCP response |
| --- | --- | --- |
| Always | `Allow` | `action: accept` |
| `persist` contains `session` | `Allow for this session` | `action: accept`, `_meta.persist: session` |
| `persist` contains `always` | `Always allow` | `action: accept`, `_meta.persist: always` |
| Non-tool request | `Deny` | `action: decline` |
| Always | `Cancel` | `action: cancel` |

Tool-call approvals deliberately have no `Deny` choice: cancellation stops the tool call. For an ordinary MCP request, `Deny` declines the request while allowing the surrounding turn to continue, whereas `Cancel` aborts the request.

Structured form and URL elicitations use the corresponding ACP elicitation capability when the client advertises it. A structured form that the client cannot render is cancelled rather than replaced with an approval that would omit required input. A message-only or URL request may use permission fallback because no structured field values are lost.

The Codex app-server currently omits the MCP request identity from form-mode elicitation parameters. `codex-acp` correlates the request with an existing MCP tool call only when exactly one pending call for that thread and server is available. Ambiguous requests receive a unique standalone `toolCallId` and include the full message and schema.

## Lifecycle and safety

Permission prompts belong to the active Codex turn. Requests for a stale or interrupted turn are rejected without opening client UI. Cancelling an ACP request, returning an unadvertised `optionId`, transport failure, and malformed client responses all fail closed.

The adapter does not reconstruct provider effects from ACP `kind` values. In particular, `allow_always` describes presentation intent but does not itself create a policy rule; only the exact Codex decision associated with the selected `optionId` can do that.

The app-server v2 request methods are the active permission surface:

- `item/commandExecution/requestApproval`
- `item/fileChange/requestApproval`
- `item/permissions/requestApproval`
- `mcpServer/elicitation/request`

Deprecated `execCommandApproval` and `applyPatchApproval` methods are not exposed as a second permission pipeline.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 30 additions & 35 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as acp from "@agentclientprotocol/sdk";
import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk";
import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler";
import {CodexApprovalHandler} from "./CodexApprovalHandler";
import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler";
import {PermissionLifecycleContext} from "./permissions/lifecycle";
import {
planImplementationApproved,
planImplementationPermissionRequest,
planImplementationToolCallId,
} from "./permissions/plan-review";
import {CodexElicitationHandler} from "./CodexElicitationHandler";
import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod";
import {clientSupportsUrlElicitation} from "./ElicitationCapabilities";
Expand Down Expand Up @@ -113,8 +119,6 @@ import {
parseAgentFileChangeReportRequest,
} from "./AgentFileChangeReport";

const IMPLEMENT_PLAN_OPTION_ID = "implement_plan";
const REVISE_PLAN_OPTION_ID = "revise_plan";

export interface SessionState {
sessionId: string,
Expand Down Expand Up @@ -255,6 +259,7 @@ export class CodexAcpServer {
private readonly sessionGenerations: Map<string, number>;
private readonly sessionOpenGenerations: Map<string, number>;
private readonly goalControlGenerations: Map<string, number>;
private readonly permissionLifecycleContexts: WeakMap<SessionState, PermissionLifecycleContext>;
private readonly codexProcessState: CodexProcessState | null;
private initializeRequest: acp.InitializeRequest | null = null;
private providerUpdate: Promise<void> | null = null;
Expand All @@ -276,6 +281,7 @@ export class CodexAcpServer {
this.sessionGenerations = new Map();
this.sessionOpenGenerations = new Map();
this.goalControlGenerations = new Map();
this.permissionLifecycleContexts = new WeakMap();
this.connection = connection;
this.codexAcpClient = codexAcpClient;
this.defaultAuthRequest = defaultAuthRequest ?? null;
Expand Down Expand Up @@ -1936,6 +1942,14 @@ export class CodexAcpServer {
return sessionState;
}

private permissionLifecycleContext(sessionState: SessionState): PermissionLifecycleContext {
const existing = this.permissionLifecycleContexts.get(sessionState);
if (existing) return existing;
const context = new PermissionLifecycleContext(sessionState);
this.permissionLifecycleContexts.set(sessionState, context);
return context;
}

private resolveSessionMcpServers(
mcpServers: Array<acp.McpServer>,
recoverFromStartup: boolean,
Expand Down Expand Up @@ -2280,10 +2294,18 @@ export class CodexAcpServer {
this.sessionFailureEpoch,
);
eventHandler = promptEventHandler;
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal);
const permissionLifecycle = this.permissionLifecycleContext(sessionState);
const permissionContext = permissionLifecycle.beginPrompt();
const approvalHandler = new CodexApprovalHandler(
this.connection,
sessionState,
permissionContext,
activePrompt.signal,
);
const elicitationHandler = new CodexElicitationHandler(
this.connection,
sessionState,
permissionContext,
this.clientCapabilities,
activePrompt.signal,
);
Expand All @@ -2295,6 +2317,7 @@ export class CodexAcpServer {
}
const completesActiveTurn = event.method === "turn/completed"
&& event.params.turn.id === sessionState.currentTurnId;
permissionContext.handleNotification(event);
await elicitationHandler.handleNotification(event);
await promptEventHandler.handleNotification(event);
if (completesActiveTurn) {
Expand Down Expand Up @@ -2646,42 +2669,14 @@ export class CodexAcpServer {
plan: CompletedPlan,
cancellationSignal: AbortSignal,
): Promise<boolean> {
const toolCallId = `plan-review:${plan.itemId}`;
const toolCallId = planImplementationToolCallId(plan);
try {
const response = await this.connection.request(
acp.methods.client.session.requestPermission,
{
sessionId: sessionState.sessionId,
toolCall: {
toolCallId,
title: "Implement this plan?",
kind: "switch_mode",
status: "pending",
rawInput: {plan: plan.text},
},
options: [
{
optionId: IMPLEMENT_PLAN_OPTION_ID,
name: "Yes, implement this plan",
kind: "allow_once",
},
{
optionId: REVISE_PLAN_OPTION_ID,
name: "No, and tell Codex what to do differently",
kind: "reject_once",
},
],
_meta: {
codex: {
kind: "plan_review",
planItemId: plan.itemId,
},
},
},
planImplementationPermissionRequest(sessionState.sessionId, plan),
{cancellationSignal},
);
const approved = response.outcome.outcome === "selected"
&& response.outcome.optionId === IMPLEMENT_PLAN_OPTION_ID;
const approved = planImplementationApproved(response);
await this.connection.notify(acp.methods.client.session.update, {
sessionId: sessionState.sessionId,
update: {
Expand Down
4 changes: 2 additions & 2 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,11 @@ export class CodexAppServerClient {

this.connection.onRequest(PermissionsApprovalRequest, async (params) => {
if (this.isStaleTurn(params.threadId, params.turnId)) {
return { permissions: {}, scope: "turn", strictAutoReview: true };
return { permissions: {}, scope: "turn", strictAutoReview: false };
}
const handler = this.approvalHandlers.get(params.threadId);
if (!handler) {
return { permissions: {}, scope: "turn", strictAutoReview: true };
return { permissions: {}, scope: "turn", strictAutoReview: false };
}
return await handler.handlePermissionsRequest(params);
});
Expand Down
Loading
Loading