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
30 changes: 28 additions & 2 deletions docs/permission-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ When Codex sends `availableDecisions`, that ordered list is authoritative. Older

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.
Unknown, malformed, empty, or internally inconsistent authoritative decision sets fail closed with `cancel`; the adapter does not invent replacement choices. The only addition to a valid decision set is opt-in; see [Continue-on-reject capability](#continue-on-reject-capability).

## File changes

Expand All @@ -129,7 +129,33 @@ File-change approvals expose the native Codex choices:
| `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.
Although the protocol decision enum also contains `decline`, the native Codex file-change prompt does not currently advertise it. Clients that opt in with the [continue-on-reject capability](#continue-on-reject-capability) also get `No, continue without making these edits` (`reject_once`, `decline`) before the `cancel` option.

## Continue-on-reject capability

Status: Experimental

Codex has two ways to reject an action: `decline` rejects it and lets the turn continue, while `cancel` rejects it and interrupts the turn. Codex does not always advertise `decline`. File-change prompts never do, and command approvals under the `untrusted` approval policy offer only `accept`, an exec-policy amendment and `cancel`. In those prompts the only `reject_once` option interrupts the turn, and `session/prompt` ends with `stopReason: "cancelled"`.

A client that wants a reject option that continues the turn advertises it on `initialize`:

```json
{
"protocolVersion": 1,
"clientCapabilities": {
"_meta": {
"continueOnReject": true
}
}
}
```

With the capability, `codex-acp` adds `decline` right before `cancel`:

- in command and network approvals whose decision set contains `cancel` but not `decline`, labelled `No, continue without running it`;
- in every file-change approval, labelled `No, continue without making these edits`.

Nothing else changes. `decline` is never added twice, never to a decision set without `cancel`, and never to the legacy additional-permissions fallback, which keeps accept and cancel only. Selecting `cancel`, or an ACP `cancelled` outcome, still returns `cancel`. Without the capability, permission requests are unchanged.

## Additional sandbox permissions

Expand Down
2 changes: 2 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as acp from "@agentclientprotocol/sdk";
import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk";
import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler";
import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler";
import {clientSupportsContinueOnReject} from "./permissions/capabilities";
import {PermissionLifecycleContext} from "./permissions/lifecycle";
import {
planImplementationApproved,
Expand Down Expand Up @@ -2895,6 +2896,7 @@ export class CodexAcpServer {
this.connection,
permissionContext,
activePrompt.signal,
clientSupportsContinueOnReject(this.clientCapabilities),
);
const elicitationHandler = new CodexElicitationHandler(
this.connection,
Expand Down
154 changes: 154 additions & 0 deletions src/__tests__/CodexACPAgent/approval-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {beforeEach, describe, expect, it, vi} from "vitest";
import * as acp from "@agentclientprotocol/sdk";
import type {
AdditionalPermissionProfile,
CommandExecutionApprovalDecision,
Expand Down Expand Up @@ -61,6 +62,18 @@ describe("Approval Events", () => {
};
}

async function setupContinueOnRejectPrompt() {
await fixture.getCodexAcpAgent().initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {_meta: {continueOnReject: true}},
});
return setupSessionWithPendingPrompt();
}

function optionIds(): string[] {
return permissionRequest().options.map((option: {optionId: string}) => option.optionId);
}

function commandParams(
availableDecisions: CommandExecutionApprovalDecision[] | unknown,
overrides: Partial<CommandParams> = {},
Expand Down Expand Up @@ -269,6 +282,104 @@ describe("Approval Events", () => {
await finish(prompt);
});

describe("with the continueOnReject client capability", () => {
const untrustedAmendment = ["ls"];
const untrustedDecisions: CommandExecutionApprovalDecision[] = [
"accept",
{acceptWithExecpolicyAmendment: {execpolicy_amendment: untrustedAmendment}},
"cancel",
];

it("offers decline before cancel when Codex's decision set lacks it", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Decline}});

const response = await fixture.sendServerRequest<{decision: unknown}>(
"item/commandExecution/requestApproval",
commandParams(untrustedDecisions, {command: "ls", proposedExecpolicyAmendment: untrustedAmendment}),
);

expect(response).toEqual({decision: "decline"});
await expect(JSON.stringify(permissionRequest(), null, 2) + "\n")
.toMatchFileSnapshot("data/approval-command-continue-on-reject.json");
await finish(prompt);
});

it("still maps an explicit cancel selection to cancel", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Cancel}});
expect(await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(["accept", "cancel"]),
)).toEqual({decision: "cancel"});
expect(optionIds()).toEqual([ApprovalOptionId.AllowOnce, ApprovalOptionId.Decline, ApprovalOptionId.Cancel]);
await finish(prompt);
});

it("maps ACP cancellation to cancel, not decline", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "cancelled"}});
expect(await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(["accept", "cancel"]),
)).toEqual({decision: "cancel"});
await finish(prompt);
});

it("keeps a single decline when Codex already advertises it", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}});
await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(["accept", "acceptForSession", "decline", "cancel"]),
);
expect(optionIds()).toEqual([
ApprovalOptionId.AllowOnce,
ApprovalOptionId.AllowForSession,
ApprovalOptionId.Decline,
ApprovalOptionId.Cancel,
]);
await finish(prompt);
});

it("does not add decline to a decision set without cancel", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}});
await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(["accept", "decline"]),
);
expect(optionIds()).toEqual([ApprovalOptionId.AllowOnce, ApprovalOptionId.Decline]);
await finish(prompt);
});

it("does not add decline to the legacy additional-permissions fallback", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}});
await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(undefined, {additionalPermissions: {network: {enabled: true}, fileSystem: null}}),
);
expect(optionIds()).toEqual([ApprovalOptionId.AllowOnce, ApprovalOptionId.Cancel]);
await finish(prompt);
});

it("ends the prompt with end_turn when Codex continues after a declined command", async () => {
const prompt = await setupContinueOnRejectPrompt();
const turnInterrupt = vi.spyOn(fixture.getCodexAppServerClient(), "turnInterrupt");
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Decline}});

expect(await fixture.sendServerRequest(
"item/commandExecution/requestApproval",
commandParams(untrustedDecisions, {command: "ls", proposedExecpolicyAmendment: untrustedAmendment}),
)).toEqual({decision: "decline"});
prompt.completeTurn();

expect((await prompt.promptPromise).stopReason).toBe("end_turn");
expect(turnInterrupt).not.toHaveBeenCalled();
});
});

it("orders native decisions as allow once, always allow, then deny", async () => {
const prompt = setupSessionWithPendingPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}});
Expand Down Expand Up @@ -646,6 +757,49 @@ describe("Approval Events", () => {
.toEqual({decision: "cancel"});
await finish(cancelPrompt);
});

it("offers only the native file-change choices without the continueOnReject capability", async () => {
const prompt = setupSessionWithPendingPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}});
await fixture.sendServerRequest("item/fileChange/requestApproval", fileParams());
expect(optionIds()).toEqual([
ApprovalOptionId.AllowOnce,
ApprovalOptionId.AllowForSession,
ApprovalOptionId.Cancel,
]);
await finish(prompt);
});

it("offers decline before cancel with the continueOnReject capability", async () => {
const prompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Decline}});

expect(await fixture.sendServerRequest("item/fileChange/requestApproval", fileParams()))
.toEqual({decision: "decline"});
expect(permissionRequest().options).toEqual([
{optionId: "allow_once", name: "Yes, proceed", kind: "allow_once"},
{optionId: "allow_for_session", name: "Yes, and don't ask again for these files", kind: "allow_always"},
{optionId: "decline", name: "No, continue without making these edits", kind: "reject_once"},
{optionId: "cancel", name: "No, and tell Codex what to do differently", kind: "reject_once"},
]);
prompt.completeTurn();
expect((await prompt.promptPromise).stopReason).toBe("end_turn");
});

it("keeps cancel and ACP cancellation distinct from decline with the continueOnReject capability", async () => {
const rejectPrompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Cancel}});
expect(await fixture.sendServerRequest("item/fileChange/requestApproval", fileParams()))
.toEqual({decision: "cancel"});
await finish(rejectPrompt);

fixture = createCodexMockTestFixture();
const cancelPrompt = await setupContinueOnRejectPrompt();
fixture.setPermissionResponse({outcome: {outcome: "cancelled"}});
expect(await fixture.sendServerRequest("item/fileChange/requestApproval", fileParams()))
.toEqual({decision: "cancel"});
await finish(cancelPrompt);
});
});

describe("additional permission approvals", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"sessionId": "test-session-id",
"toolCall": {
"toolCallId": "command-item",
"kind": "execute",
"status": "pending",
"title": "Run command",
"rawInput": {
"command": "ls",
"cwd": "/workspace"
}
},
"options": [
{
"optionId": "allow_once",
"name": "Yes, proceed",
"kind": "allow_once"
},
{
"optionId": "accept_execpolicy_amendment",
"name": "Yes, and don't ask again for commands that start with `ls`",
"kind": "allow_always"
},
{
"optionId": "decline",
"name": "No, continue without running it",
"kind": "reject_once"
},
{
"optionId": "cancel",
"name": "No, and tell Codex what to do differently",
"kind": "reject_once"
}
],
"_meta": {
"permission": {
"version": 1,
"title": "Run command?",
"description": "Needed to verify the changes."
}
}
}
21 changes: 21 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ describeE2E("E2E read-only mode file permission tests", () => {
});
});

describeE2E("E2E continue-on-reject file permission tests", () => {
let fixture: SpawnedAgentFixture;

beforeEach(async () => {
fixture = await createAuthenticatedFixture(AgentMode.ReadOnly, undefined, {continueOnReject: true});
});

afterEach(async () => {
await fixture.dispose();
});

it("continues the turn when a workspace file edit is declined", async () => {
fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.Decline));
const filePath = newFilePathIn(fixture.workspaceDir);
const turn = await askAgentToEditFile(fixture, filePath);

expect(turn.response.stopReason, turn.diagnostics()).toBe("end_turn");
expect(fs.existsSync(filePath), turn.diagnostics()).toBe(false);
});
});

describeE2E("E2E workspace access mode file permission tests", () => {
let fixture: SpawnedAgentFixture;

Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,36 @@ describeE2E("E2E full-access mode shell permission tests", () => {
});
});

describeE2E("E2E continue-on-reject shell permission tests", () => {
let fixture: SpawnedAgentFixture;

beforeEach(async () => {
fixture = await createAuthenticatedFixture(AgentMode.ReadOnly, undefined, {continueOnReject: true});
});

afterEach(async () => {
await fixture.dispose();
});

it("continues the turn when a workspace write command is declined", async () => {
fixture.setPermissionResponder(createPermissionResponder("execute", ApprovalOptionId.Decline));
const filePath = path.join(fixture.workspaceDir, generateFileNameForTest());
const sessionId = (await fixture.createSession()).sessionId;
const response = await fixture.connection.prompt({
sessionId,
prompt: [{
type: "text",
text: `Use your shell tool to run exactly \`printf 'blocked' > '${filePath}'\`. Do not modify files any other way.`,
}],
});

const diagnostics = `stopReason=${response.stopReason}; agent said: ${fixture.readText(sessionId)}`;
expect(fixture.readPermissionRequests(sessionId, "execute").length, diagnostics).toBeGreaterThanOrEqual(1);
expectEndTurn(response);
expect(fs.existsSync(filePath), diagnostics).toBe(false);
});
});

describeE2E("E2E shell cancellation tests", () => {
let fixture: SpawnedAgentFixture | null = null;

Expand Down
Loading