From dab10890791cde6e933133b7b173433427f10986 Mon Sep 17 00:00:00 2001 From: Subash karki Date: Sun, 27 Sep 2026 10:16:54 -0400 Subject: [PATCH] feat: add ask-every-time session mode Add an `ask-always` access preset ("Ask every time") that sends `approvalPolicy: "untrusted"` with the user as reviewer and the same workspace-write sandbox as `workspace-write` (no network). Every command and every file change, including plain reads, then reaches `session/request_permission`. The mode is not the default and the existing presets are unchanged. --- README.md | 2 +- readme-dev.md | 2 +- src/AgentMode.ts | 24 +++++++- .../data/ask-always-mode-policy.json | 15 +++++ .../e2e/acp-e2e-file-approval.test.ts | 24 ++++++++ .../e2e/acp-e2e-shell-approval.test.ts | 55 +++++++++++++++++++ .../session-config-options.test.ts | 16 +++++- 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/ask-always-mode-policy.json diff --git a/README.md b/README.md index 35bf5d3a1..ef3c23a88 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - `CODEX_CONFIG` - JSON object merged into the Codex session config. - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. -- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, or `agent-full-access`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, `agent-full-access`, or `ask-always`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. diff --git a/readme-dev.md b/readme-dev.md index b8f67cb3a..ca1fe69f4 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -9,7 +9,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `CODEX_CONFIG` - JSON object merged into the Codex session config. - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. -- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, or `agent-full-access`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, `agent-full-access`, or `ask-always`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. diff --git a/src/AgentMode.ts b/src/AgentMode.ts index 8f027451d..24eb18196 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -48,6 +48,22 @@ export class AgentMode { }, "read-only", ); + static readonly AskAlways = new AgentMode( + "ask-always", + "Ask every time", + "Ask before every command and file edit, including reads. Approved commands run in the workspace sandbox without network access.", + "standard", + "untrusted", + "user", + { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + "workspace-write", + ); static readonly WorkspaceWrite = new AgentMode( "workspace-write", "Workspace access", @@ -127,7 +143,13 @@ export class AgentMode { } static all(): AgentMode[] { - return [AgentMode.ReadOnly, AgentMode.WorkspaceWrite, AgentMode.Agent, AgentMode.AgentFullAccess]; + return [ + AgentMode.ReadOnly, + AgentMode.WorkspaceWrite, + AgentMode.Agent, + AgentMode.AgentFullAccess, + AgentMode.AskAlways, + ]; } static find(modeId: string): AgentMode | null { diff --git a/src/__tests__/CodexACPAgent/data/ask-always-mode-policy.json b/src/__tests__/CodexACPAgent/data/ask-always-mode-policy.json new file mode 100644 index 000000000..b0c79bac2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/ask-always-mode-policy.json @@ -0,0 +1,15 @@ +[ + { + "approvalPolicy": "untrusted", + "approvalsReviewer": "user", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [ + "/test/extra" + ], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + } + } +] diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts index b2ca73b84..16cc540b5 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts @@ -64,6 +64,30 @@ describeE2E("E2E workspace access mode file permission tests", () => { }); }); +describeE2E("E2E ask-always mode file permission tests", () => { + let fixture: SpawnedAgentFixture; + + beforeEach(async () => { + fixture = await createAuthenticatedFixture(AgentMode.AskAlways); + }); + + afterEach(async () => { + await fixture.dispose(); + }); + + it("requests permission before applying a workspace file edit", async () => { + fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.AllowOnce)); + const sessionId = await expectFileEditApplied(fixture, newFilePathIn(fixture.workspaceDir)); + expect(fixture.readPermissionRequests(sessionId, "edit").length).toBeGreaterThanOrEqual(1); + expect(fixture.readPermissionRequests(sessionId, "execute")).toHaveLength(0); + }); + + it("does not apply a workspace file edit when permission is cancelled", async () => { + fixture.setPermissionResponder(() => createPermissionResponse(null)); + await expectFileEditBlocked(fixture, newFilePathIn(fixture.workspaceDir)); + }); +}); + describeE2E("E2E switching to read-only mode file permission tests", () => { let fixture: SpawnedAgentFixture; diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts index d3d59f4ca..ce3695d3b 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts @@ -146,6 +146,61 @@ describeE2E("E2E read-only mode shell permission tests", () => { }); }); +describeE2E("E2E ask-always mode shell permission tests", () => { + let fixture: SpawnedAgentFixture; + + beforeEach(async () => { + fixture = await createAuthenticatedFixture(AgentMode.AskAlways); + }); + + afterEach(async () => { + await fixture.dispose(); + }); + + it("requests permission for a command that only reads the workspace", async () => { + const fileName = generateFileNameForTest(); + fs.writeFileSync(path.join(fixture.workspaceDir, fileName), "ask-always e2e"); + fixture.setPermissionResponder(createPermissionResponder("execute", ApprovalOptionId.AllowOnce)); + const sessionId = (await fixture.createSession()).sessionId; + const response = await fixture.connection.prompt({ + sessionId, + prompt: [{ + type: "text", + text: `Use your shell tool to run exactly \`cat '${fileName}'\` and nothing else.`, + }], + }); + + expectEndTurn(response); + expect(fixture.readPermissionRequests(sessionId, "execute").length).toBeGreaterThanOrEqual(1); + expect(fixture.readPermissionRequests(sessionId, "edit")).toHaveLength(0); + }); + + it("requests permission for a command that writes inside the workspace", async () => { + fixture.setPermissionResponder(createPermissionResponder("execute", ApprovalOptionId.AllowOnce)); + const sessionId = await writeToFile(fixture, path.join(fixture.workspaceDir, generateFileNameForTest())); + + expect(fixture.readPermissionRequests(sessionId, "execute").length).toBeGreaterThanOrEqual(1); + expect(fixture.readPermissionRequests(sessionId, "edit")).toHaveLength(0); + }); + + it("does not write inside the workspace when shell permission is cancelled", async () => { + fixture.setPermissionResponder(() => createPermissionResponse(null)); + 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.`, + }], + }); + + expect(fs.existsSync(filePath), + `stopReason=${response.stopReason}; agent said: ${fixture.readText(sessionId)}`, + ).toBe(false); + }); +}); + describeE2E("E2E workspace access mode shell permission tests", () => { let fixture: SpawnedAgentFixture; diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index eea818396..0d4f6a953 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -132,13 +132,18 @@ describe("Session config options", () => { name: "Full access", description: "Unrestricted access to the internet and any file on your computer", }, + { + value: "ask-always", + name: "Ask every time", + description: "Ask before every command and file edit, including reads. Approved commands run in the workspace sandbox without network access.", + }, ], }); expect((modeOption as any).options.map((o: any) => o.value)).toEqual( AgentMode.all().map(m => m.id) ); expect(response.modes?.availableModes.map(mode => mode.id)).toEqual([ - "read-only", "workspace-write", "agent", "agent-full-access", + "read-only", "workspace-write", "agent", "agent-full-access", "ask-always", ]); }); @@ -263,6 +268,13 @@ describe("Session config options", () => { {selection: "INITIAL_AGENT_MODE", initialMode: "workspace-write", modeId: "workspace-write"}, {selection: "session/set_mode", initialMode: "read-only", modeId: "workspace-write"}, {selection: "session/set_config_option", initialMode: "read-only", modeId: "workspace-write"}, + {selection: "INITIAL_AGENT_MODE", initialMode: "ask-always", modeId: "ask-always"}, + {selection: "session/set_mode", initialMode: "agent", modeId: "ask-always"}, + {selection: "session/set_mode", initialMode: "workspace-write", modeId: "ask-always"}, + {selection: "session/set_config_option", initialMode: "agent-full-access", modeId: "ask-always"}, + {selection: "session/set_config_option", initialMode: "read-only", modeId: "ask-always"}, + {selection: "session/set_mode", initialMode: "ask-always", modeId: "read-only"}, + {selection: "session/set_config_option", initialMode: "ask-always", modeId: "workspace-write"}, ])("applies $modeId permissions after $selection from $initialMode", async ({selection, initialMode, modeId}) => { vi.stubEnv("INITIAL_AGENT_MODE", initialMode); const {fast} = buildModels(); @@ -301,7 +313,7 @@ describe("Session config options", () => { }); // Assert the actual outgoing policy, independently of the preset object. - // Additional session roots are writable only in the workspace-write preset. + // Additional session roots are writable only in the workspace-write sandbox presets. const policies = turnStart.mock.calls.map(([{approvalPolicy, approvalsReviewer, sandboxPolicy}]) => ({ approvalPolicy, approvalsReviewer, sandboxPolicy, }));