From 0f2eec496c875d139182c0d186f28edeefe8a0d5 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 10:53:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(acp):=20bypassPermissions=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=8E=BB=E6=8E=89=20opt-in?= =?UTF-8?q?=20=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 bypassPermissions 需要本地显式 opt-in(ACP_PERMISSION_MODE 环境变量、 CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS 环境变量、或 settings.permissions.defaultMode) 才会出现在 modes 列表里 —— 标准客户端看不到这个 mode,永远没法切换。 去掉 opt-in 后,只要进程级允许(非 root 或 IS_SANDBOX=1)就显示。 - permissionMode: isAcpBypassPermissionModeAvailable 只保留进程级检查,删除 isAcpBypassLocallyEnabled / isSettingsBypassPermissionMode / isTruthyEnv 等 只服务于 opt-in 的辅助函数 - createSessionMethod: 调用方去掉 settingsMode 参数 - agent.test: 反转所有依赖 "bypass 需要 opt-in" 的断言 Co-Authored-By: glm-5.2 --- src/services/acp/__tests__/agent.test.ts | 62 +++++++------------ src/services/acp/agent/createSessionMethod.ts | 8 +-- src/services/acp/agent/permissionMode.ts | 45 +++++--------- 3 files changed, 43 insertions(+), 72 deletions(-) diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index b3c58777b..141e16493 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -411,29 +411,23 @@ describe('AcpAgent', () => { expect(res.modes?.currentModeId).toBe('plan') }) - test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => { - mockGetSettings.mockImplementationOnce(() => ({ - permissions: { defaultMode: 'acceptEdits' }, - })) - const consoleErrorSpy = spyOn(console, 'error').mockImplementation( - () => {}, - ) + test('honors _meta.permissionMode bypass without any opt-in (always available when process allows)', async () => { + // bypass is exposed by default; only the root/sandbox process guard remains. const agent = new AcpAgent(makeConn()) - try { - await expect( - agent.newSession({ - cwd: '/tmp', - _meta: { permissionMode: 'bypassPermissions' }, - } as any), - ).rejects.toThrow('Mode not available: bypassPermissions') + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'bypassPermissions' }, + } as any) - expect(consoleErrorSpy).not.toHaveBeenCalled() - } finally { - consoleErrorSpy.mockRestore() - } + expect(res.modes?.currentModeId).toBe('bypassPermissions') + expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain( + 'bypassPermissions', + ) }) - test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => { + test('honors _meta.permissionMode bypass regardless of local env gate', async () => { + // The old CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS opt-in no longer gates availability, + // but setting it should still not break the request. process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' const agent = new AcpAgent(makeConn()) const res = await agent.newSession({ @@ -987,28 +981,15 @@ describe('AcpAgent', () => { ).rejects.toThrow('Session not found') }) - test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => { + test('availableModes includes bypassPermissions by default (no opt-in needed)', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) const session = agent.sessions.get(sessionId) const modeIds = session?.modes.availableModes.map((m: any) => m.id) - expect(modeIds).not.toContain('bypassPermissions') + expect(modeIds).toContain('bypassPermissions') }) - test('rejects bypassPermissions without a local ACP bypass gate', async () => { - const agent = new AcpAgent(makeConn()) - const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - await expect( - agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any), - ).rejects.toThrow('Mode not available') - - const session = agent.sessions.get(sessionId) - expect(session?.modes.currentModeId).toBe('default') - expect(session?.appState.toolPermissionContext.mode).toBe('default') - }) - - test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => { - process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' + test('can switch to bypassPermissions without any opt-in gate', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) await agent.setSessionMode({ @@ -1023,7 +1004,8 @@ describe('AcpAgent', () => { }) test('rejects bypassPermissions when the session does not expose it', async () => { - process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' + // Even though bypass is available by default, removeBypassMode simulates a session + // where the mode was stripped (e.g., future custom filter). The rejection still fires. const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) const session = agent.sessions.get(sessionId) @@ -1069,15 +1051,17 @@ describe('AcpAgent', () => { const session = agent.sessions.get(sessionId) removeBypassMode(session) - // The value is rejected because it is not in the option's listed values - // (config-option validation runs before the mode-availability check). + // bypassPermissions passes the config-option layer (it's still listed in the + // option's options array — removeBypassMode only strips it from modes.availableModes + // and isBypassPermissionsModeAvailable), then applySessionMode rejects it with + // "Mode not available". This covers the second of the two validation layers. await expect( agent.setSessionConfigOption({ sessionId, configId: 'mode', value: 'bypassPermissions', } as any), - ).rejects.toThrow(/Invalid value 'bypassPermissions'/) + ).rejects.toThrow('Mode not available') expect(session?.modes.currentModeId).toBe('default') expect(session?.appState.toolPermissionContext.mode).toBe('default') diff --git a/src/services/acp/agent/createSessionMethod.ts b/src/services/acp/agent/createSessionMethod.ts index 5e1d0ff74..34059aaf8 100644 --- a/src/services/acp/agent/createSessionMethod.ts +++ b/src/services/acp/agent/createSessionMethod.ts @@ -137,10 +137,10 @@ async function createSession( // Parse MCP servers from ACP params // MCP server config is handled separately in the tools system - // ACP clients can expose bypass only when both the process and local config allow it. - const isBypassAvailable = isAcpBypassPermissionModeAvailable( - settingsPermissionMode, - ) + // bypassPermissions is exposed to ACP clients whenever the process itself allows it + // (non-root or sandbox). The previous additional opt-in gate made the mode invisible + // to standard clients and defeated the purpose of listing it. See permissionMode.ts. + const isBypassAvailable = isAcpBypassPermissionModeAvailable() // Create a mutable AppState for the session const appState: AppState = { diff --git a/src/services/acp/agent/permissionMode.ts b/src/services/acp/agent/permissionMode.ts index a16dbb2c7..009b06425 100644 --- a/src/services/acp/agent/permissionMode.ts +++ b/src/services/acp/agent/permissionMode.ts @@ -26,10 +26,10 @@ export function resolveSessionPermissionMode( ) if ( metaResolved === 'bypassPermissions' && - !isAcpBypassPermissionModeAvailable(settingsMode) + !isAcpBypassPermissionModeAvailable() ) { throw new Error( - 'Mode not available: bypassPermissions requires a local ACP bypass opt-in.', + 'Mode not available: bypassPermissions cannot run as root (start the agent as a non-root user, or set IS_SANDBOX=1).', ) } @@ -78,14 +78,20 @@ export function hasOwnField( return !!value && Object.hasOwn(value, key) } -export function isAcpBypassPermissionModeAvailable( - settingsMode?: unknown, -): boolean { - return ( - isProcessBypassPermissionModeAvailable() && - (isAcpBypassLocallyEnabled() || - isSettingsBypassPermissionMode(settingsMode)) - ) +/** + * Whether bypassPermissions is selectable by ACP clients. + * + * The previous implementation required a local opt-in (ACP_PERMISSION_MODE env var, + * CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS env var, or settings.permissions.defaultMode). + * That gate made the mode invisible to standard clients unless the operator already + * pre-configured it — defeating the point of exposing it through the ACP mode list. + * + * The only remaining guard is the process-level one: bypass must not silently run + * as root (where every skipped permission check is a privilege boundary crossed), + * unless explicitly marked as a sandbox. + */ +export function isAcpBypassPermissionModeAvailable(): boolean { + return isProcessBypassPermissionModeAvailable() } function isProcessBypassPermissionModeAvailable(): boolean { @@ -94,22 +100,3 @@ function isProcessBypassPermissionModeAvailable(): boolean { if (typeof process.getuid === 'function') return process.getuid() !== 0 return true } - -function isAcpBypassLocallyEnabled(): boolean { - return ( - process.env.ACP_PERMISSION_MODE === 'bypassPermissions' || - isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS) - ) -} - -function isSettingsBypassPermissionMode(settingsMode: unknown): boolean { - try { - return resolvePermissionMode(settingsMode) === 'bypassPermissions' - } catch { - return false - } -} - -function isTruthyEnv(value: string | undefined): boolean { - return value === '1' || value?.toLowerCase() === 'true' -}