Skip to content

Commit f9f78da

Browse files
fix(agent): allow bypassPermissions switch in sandboxed sessions (#1858)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a6628fb commit f9f78da

10 files changed

Lines changed: 133 additions & 37 deletions

File tree

apps/code/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { getFilePath } from "@utils/getFilePath";
2727
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2828
import { getSessionService } from "../service/service";
29+
import { flattenSelectOptions } from "../stores/sessionStore";
2930
import {
3031
useSessionViewActions,
3132
useShowRawLogs,
@@ -70,6 +71,25 @@ interface SessionViewProps {
7071
const DEFAULT_ERROR_MESSAGE =
7172
"Failed to resume this session. The working directory may have been deleted. Please start a new session.";
7273

74+
/**
75+
* When an allow_always permission is granted outside a mode-switch prompt,
76+
* ratchet the session to the closest "auto-accept edits" preset offered by
77+
* this adapter's mode catalog. Claude exposes `acceptEdits`; Codex has no
78+
* exact equivalent, so fall back to `auto`. Returns undefined if neither is
79+
* available (in which case leave the current mode untouched).
80+
*/
81+
function resolveAllowAlwaysUpgradeMode(
82+
modeOption: ReturnType<typeof useModeConfigOptionForTask>,
83+
): string | undefined {
84+
if (modeOption?.type !== "select") return undefined;
85+
const availableIds = new Set(
86+
flattenSelectOptions(modeOption.options).map((opt) => opt.value),
87+
);
88+
if (availableIds.has("acceptEdits")) return "acceptEdits";
89+
if (availableIds.has("auto")) return "auto";
90+
return undefined;
91+
}
92+
7393
export function SessionView({
7494
events,
7595
taskId,
@@ -109,6 +129,11 @@ export function SessionView({
109129

110130
useEffect(() => {
111131
if (allowBypassPermissions) return;
132+
// Cloud runs execute in an isolated sandbox where bypass is safe, and the
133+
// agent's own gate (ALLOW_BYPASS = !IS_ROOT || IS_SANDBOX) already permits
134+
// it regardless of this local preference. Auto-reverting here would clobber
135+
// the user's explicit plan-approval choice and strand them in Plan Mode.
136+
if (isCloud) return;
112137
const isBypass =
113138
currentModeId === "bypassPermissions" || currentModeId === "full-access";
114139
if (isBypass && taskId) {
@@ -118,7 +143,7 @@ export function SessionView({
118143
"default",
119144
);
120145
}
121-
}, [allowBypassPermissions, currentModeId, taskId]);
146+
}, [allowBypassPermissions, currentModeId, taskId, isCloud]);
122147

123148
const handleModeChange = useCallback(
124149
(nextMode: string) => {
@@ -227,11 +252,18 @@ export function SessionView({
227252
const isModeSwitch =
228253
firstPendingPermission.toolCall?.kind === "switch_mode";
229254
if (selectedOption?.kind === "allow_always" && !isModeSwitch) {
230-
getSessionService().setSessionConfigOptionByCategory(
231-
taskId,
232-
"mode",
233-
"acceptEdits",
234-
);
255+
// Pick the adapter-appropriate "upgrade" mode. Claude exposes
256+
// acceptEdits; Codex does not — its closest analogue is auto. Resolve
257+
// against the session's advertised mode catalog so the footer label
258+
// stays coherent with the dropdown contents.
259+
const upgradeMode = resolveAllowAlwaysUpgradeMode(modeOption);
260+
if (upgradeMode) {
261+
getSessionService().setSessionConfigOptionByCategory(
262+
taskId,
263+
"mode",
264+
upgradeMode,
265+
);
266+
}
235267
}
236268

237269
if (customInput) {
@@ -268,7 +300,14 @@ export function SessionView({
268300

269301
requestFocus(sessionId);
270302
},
271-
[firstPendingPermission, taskId, onSendPrompt, requestFocus, sessionId],
303+
[
304+
firstPendingPermission,
305+
taskId,
306+
onSendPrompt,
307+
requestFocus,
308+
sessionId,
309+
modeOption,
310+
],
272311
);
273312

274313
const handlePermissionCancel = useCallback(async () => {

apps/code/src/renderer/features/sessions/service/service.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,35 @@ const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE =
100100
* trpc query, which is async. Callers populate them by calling
101101
* `fetchAndApplyCloudPreviewOptions` after the session exists in the store.
102102
*/
103+
function extractLatestConfigOptionsFromEntries(
104+
entries: StoredLogEntry[],
105+
): SessionConfigOption[] | undefined {
106+
let latest: SessionConfigOption[] | undefined;
107+
for (const entry of entries) {
108+
if (
109+
entry.type !== "notification" ||
110+
entry.notification?.method !== "session/update"
111+
) {
112+
continue;
113+
}
114+
const params = entry.notification.params as
115+
| {
116+
update?: {
117+
sessionUpdate?: string;
118+
configOptions?: SessionConfigOption[];
119+
};
120+
}
121+
| undefined;
122+
if (
123+
params?.update?.sessionUpdate === "config_option_update" &&
124+
params.update.configOptions
125+
) {
126+
latest = params.update.configOptions;
127+
}
128+
}
129+
return latest;
130+
}
131+
103132
function buildCloudDefaultConfigOptions(
104133
initialMode: string | undefined,
105134
adapter: Adapter = "claude",
@@ -2883,6 +2912,20 @@ export class SessionService {
28832912
(update.kind === "logs" || update.kind === "snapshot") &&
28842913
update.newEntries.length > 0
28852914
) {
2915+
// Cloud streams deliver `session/update` notifications as regular log
2916+
// entries rather than live ACP messages. Without this, config changes
2917+
// made mid-run (e.g. plan-approval switching to bypassPermissions) never
2918+
// reach the session store and the footer mode selector stays stale.
2919+
const latestConfigOptions = extractLatestConfigOptionsFromEntries(
2920+
update.newEntries,
2921+
);
2922+
if (latestConfigOptions) {
2923+
sessionStoreSetters.updateSession(taskRunId, {
2924+
configOptions: latestConfigOptions,
2925+
});
2926+
setPersistedConfigOptions(taskRunId, latestConfigOptions);
2927+
}
2928+
28862929
const session = sessionStoreSetters.getSessions()[taskRunId];
28872930
const currentCount = session?.processedLineCount ?? 0;
28882931
const expectedCount = update.totalEntryCount;

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
10261026
configOptions: this.session.configOptions,
10271027
},
10281028
});
1029+
1030+
// Notify the agent-server so its cached permissionMode stays in sync.
1031+
// Without this, cloud sessions that change mode via plan approval or
1032+
// setSessionMode use a stale mode for relay decisions.
1033+
if (configId === "mode") {
1034+
await this.client.sessionUpdate({
1035+
sessionId: this.sessionId,
1036+
update: {
1037+
sessionUpdate: "current_mode_update",
1038+
currentModeId: value,
1039+
},
1040+
});
1041+
}
10291042
}
10301043

10311044
private async applySessionMode(modeId: string): Promise<void> {
@@ -1322,6 +1335,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
13221335
logger: this.logger,
13231336
updateConfigOption: (configId: string, value: string) =>
13241337
this.updateConfigOption(configId, value),
1338+
applySessionMode: (modeId: string) => this.applySessionMode(modeId),
13251339
allowedDomains,
13261340
});
13271341
}

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ interface ToolHandlerContext {
5656
fileContentCache: { [key: string]: string };
5757
logger: Logger;
5858
updateConfigOption: (configId: string, value: string) => Promise<void>;
59+
applySessionMode: (modeId: string) => Promise<void>;
5960
allowedDomains?: string[];
6061
}
6162

@@ -167,25 +168,14 @@ async function applyPlanApproval(
167168
context: ToolHandlerContext,
168169
updatedInput: Record<string, unknown>,
169170
): Promise<ToolPermissionResult> {
170-
const { session } = context;
171-
172171
if (
173172
response.outcome?.outcome === "selected" &&
174173
(response.outcome.optionId === "auto" ||
175174
response.outcome.optionId === "default" ||
176175
response.outcome.optionId === "acceptEdits" ||
177176
response.outcome.optionId === "bypassPermissions")
178177
) {
179-
session.permissionMode = response.outcome
180-
.optionId as typeof session.permissionMode;
181-
await session.query.setPermissionMode(response.outcome.optionId);
182-
await context.client.sessionUpdate({
183-
sessionId: context.sessionId,
184-
update: {
185-
sessionUpdate: "current_mode_update",
186-
currentModeId: response.outcome.optionId,
187-
},
188-
});
178+
await context.applySessionMode(response.outcome.optionId);
189179
await context.updateConfigOption("mode", response.outcome.optionId);
190180

191181
return {
@@ -215,10 +205,9 @@ async function applyPlanApproval(
215205
async function handleEnterPlanModeTool(
216206
context: ToolHandlerContext,
217207
): Promise<ToolPermissionResult> {
218-
const { session, toolInput } = context;
208+
const { toolInput } = context;
219209

220-
session.permissionMode = "plan";
221-
await session.query.setPermissionMode("plan");
210+
await context.applySessionMode("plan");
222211
await context.updateConfigOption("mode", "plan");
223212

224213
return {

packages/agent/src/adapters/claude/permissions/permission-options.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
2-
import { IS_ROOT } from "../../../utils/common";
2+
import { ALLOW_BYPASS } from "../../../utils/common";
33
import { BASH_TOOLS, READ_TOOLS, SEARCH_TOOLS, WRITE_TOOLS } from "../tools";
44

55
export interface PermissionOption {
@@ -92,8 +92,6 @@ export function buildPermissionOptions(
9292
return permissionOptions("Yes, always allow");
9393
}
9494

95-
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
96-
9795
export function buildExitPlanModePermissionOptions(): PermissionOption[] {
9896
const options: PermissionOption[] = [];
9997

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
317317
stderr: (err) => params.logger.error(err),
318318
cwd: params.cwd,
319319
includePartialMessages: true,
320-
allowDangerouslySkipPermissions: !IS_ROOT,
320+
allowDangerouslySkipPermissions: !IS_ROOT || !!process.env.IS_SANDBOX,
321321
permissionMode: params.permissionMode,
322322
canUseTool: params.canUseTool,
323323
executable: "node",

packages/agent/src/adapters/codex/codex-agent.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,20 @@ export class CodexAcpAgent extends BaseAcpAgent {
667667
if (response.configOptions) {
668668
this.sessionState.configOptions = response.configOptions;
669669
}
670+
if (params.configId === "mode" && typeof params.value === "string") {
671+
// Signal the mode change to agent-server so its session.permissionMode
672+
// cache (used by shouldRelayPermissionToClient) stays in sync with the
673+
// real Codex mode. Claude emits the same signal from its equivalent
674+
// handler; without it, the agent-server's relay decisions for cloud
675+
// runs would use a stale mode and silently auto-approve tool calls.
676+
await this.client.sessionUpdate({
677+
sessionId: this.sessionId,
678+
update: {
679+
sessionUpdate: "current_mode_update",
680+
currentModeId: params.value,
681+
},
682+
});
683+
}
670684
return response;
671685
}
672686

packages/agent/src/execution-mode.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { IS_ROOT } from "./utils/common";
1+
import { ALLOW_BYPASS } from "./utils/common";
22

33
export interface ModeInfo {
44
id: string;
55
name: string;
66
description: string;
77
}
88

9-
// Helper constant that can easily be toggled for env/feature flag/etc
10-
const ALLOW_BYPASS = !IS_ROOT;
11-
129
const availableModes: ModeInfo[] = [
1310
{
1411
id: "default",
@@ -58,9 +55,9 @@ export function isCodeExecutionMode(mode: string): mode is CodeExecutionMode {
5855
}
5956

6057
export function getAvailableModes(): ModeInfo[] {
61-
return IS_ROOT
62-
? availableModes.filter((m) => m.id !== "bypassPermissions")
63-
: availableModes;
58+
return ALLOW_BYPASS
59+
? availableModes
60+
: availableModes.filter((m) => m.id !== "bypassPermissions");
6461
}
6562

6663
// --- Codex-native modes ---
@@ -98,7 +95,7 @@ if (ALLOW_BYPASS) {
9895
}
9996

10097
export function getAvailableCodexModes(): ModeInfo[] {
101-
return IS_ROOT
102-
? codexModes.filter((m) => m.id !== "full-access")
103-
: codexModes;
98+
return ALLOW_BYPASS
99+
? codexModes
100+
: codexModes.filter((m) => m.id !== "full-access");
104101
}

packages/agent/src/server/agent-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export class AgentServer {
297297
}
298298

299299
private shouldRelayPermissionToClient(mode: PermissionMode): boolean {
300-
return mode === "default" || mode === "auto";
300+
return mode === "default" || mode === "auto" || mode === "read-only";
301301
}
302302

303303
private createApp(): Hono {

packages/agent/src/utils/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const IS_ROOT =
2323
typeof process !== "undefined" &&
2424
(process.geteuid?.() ?? process.getuid?.()) === 0;
2525

26+
export const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
27+
2628
export function unreachable(value: never, logger: Logger): void {
2729
let valueAsString: string;
2830
try {

0 commit comments

Comments
 (0)