Add full access mode and fix bypass commit prompts (Issue 1097) (#1110)

* Add full access permission mode

Introduce a Full Access mode as a second-level dangerous permission option that bypasses normal confirmation prompts and hard safety-check prompts while still preserving deny decisions.

Wire fullAccess through permission mode types, SDK schemas/types, CLI and REPL control paths, settings, mode cycling, spawned teammate inheritance, prompt speculation, and setup safety checks.

Update permission handling so Full Access skips ask rules, requiresUserInteraction prompts, content-specific ask results, safety-check asks, and hook-forced asks while preserving updatedInput from tool permission checks.

Add a separate Full Access warning acknowledgement and render Full Access selections in red to make the higher-risk mode visually distinct.

Allow the project-local .git/OPENCLAUDE_COMMIT_MSG helper file in dangerous modes for /commit while keeping default mode and other .git paths protected by safety prompts.

Add focused regression tests for Full Access prompt bypass behavior, hook ask handling, commit message file permissions, mode cycling, spawned teammate propagation, and SDK permission mappings.

* fix: restore sdk permission fail-closed behavior

Preserve host canUseTool and onPermissionRequest enforcement in fullAccess instead of short-circuiting around SDK policy callbacks.

Keep the default SDK permission path fail-closed when no host callback is configured, while still allowing interactive tools to surface guidance prompts under fullAccess.

Add focused regression coverage for SDK permission routing and fullAccess user-interaction behavior, plus filesystem coverage for the project-local OPENCLAUDE_COMMIT_MSG path.

* fix: complete full access permission mode integrations

- keep Full Access out of persisted default permission mode settings

- sync Full Access to Claude in Chrome skip-all permission mode

- restore Full Access correctly when exiting plan mode

- add regression coverage for settings, Chrome sync, and plan-mode exit

* test: harden dangerous mode startup flow

* feat: add permission mode management tab

Add a dedicated permission mode tab for switching session modes from the permissions UI.

Keep dangerous modes visible when currently active, route dangerous mode changes through the confirmation dialog without exiting settings, and surface availability errors for auto or bypass modes.

Also add focused tests for permission mode option visibility.

* feat: add full access approval flows

Add fullAccess as an approval option across file, shell, skill, monitor, web fetch, fallback, and plan-exit permission prompts.

Introduce a shared dangerous-mode confirmation hook, wire fullAccess session mode updates through the permission handlers, and gate the new option on dangerous-mode availability.

Also fix the plan-exit follow-up review findings by preserving hook order around the dangerous-mode dialog and restoring Shift+Tab to the explicit accept-edits approval path.

Verified with focused permission tests and Bun module import smoke checks.

* Harden dangerous permission mode boundaries

Tighten fullAccess and bypassPermissions entry paths so elevated mode always respects explicit local confirmation and authoritative org policy gates.

This hardens SDK and bridge activation, Chrome integration, session resume and rewind restoration, team and plan mode transitions, and shared permission update handling. It also keeps session dangerous-mode state in sync and adds focused regression coverage for permission setup, killswitch behavior, conversation recovery, and SDK permission flows.

* refactor: centralize permission mode transitions

Route permission mode changes through shared decision and live-transition helpers so dangerous/full-access confirmation, plan/auto side effects, and mode application stay aligned across CLI, REPL, prompts, and swarm surfaces.

Add a shared UI request hook for resolved dangerous-mode confirmations, persist in-session dangerous-mode acceptance, and remove duplicated request/confirm/apply flows from prompt input, teams, plan exit, and permission settings.

Also fix follow-up correctness issues by validating all setMode updates consistently, applying live permission updates before persisting them, and rebasing those live updates on the latest permission context to avoid partial commits or stale-state overwrites.

* refactor: simplify permission request flows

Centralize permission mode changes behind requestPermissionModeChange and reuse it from the CLI, REPL bridge, inbox poller, and UI callers.

Consolidate duplicated permission request behavior by introducing shared shell and simple permission helpers, routing file permission actions through a shared executor, and unifying remote permission queue-item construction.

Add a shared PermissionScaffold for the common dialog frame, remove redundant shell option/helper modules, and keep focused permission mode transition coverage in permissionSetup tests.

* Enable full access from the permissions UI

Expose bypass/full-access modes in the /permissions picker so dangerous modes can be enabled in-session instead of only via launch flags.

Propagate a session-only bypass-enable signal through the permission mode change flow, preserve the existing dangerous-mode confirmation and policy checks, and keep the session marked as bypass-capable after the user enables one of the dangerous modes.

Also add targeted tests covering picker visibility, local session unlock behavior, and the post-enable session state.

* Refine bypass permissions warning copy

* fix(powershell): anchor commit message .git exception to project root

Align the PowerShell .git write safety exception for
.git/OPENCLAUDE_COMMIT_MSG with the shared filesystem permission rule.
The PowerShell helper was resolving the path from the mutable shell cwd,
which made the bypassPermissions and fullAccess cases order-sensitive in
the full test suite. Resolve the exception from getOriginalCwd() instead
so the temp commit message file is only exempted inside the project root
.git directory while other .git writes still require a safety prompt.

Verified with:
- bun test src/tools/PowerShellTool/powershellPermissions.test.ts --max-concurrency=1
- bun test src/utils/permissions/filesystem.test.ts --max-concurrency=1
- bun test src/tools/PowerShellTool src/utils/permissions --max-concurrency=1

* test: fix dangerous mode prompt suite hang

* Fix monitor permission test isolation

* Harden monitor permission state selector

---------

Co-authored-by: JATMN <12479882+jatmn@users.noreply.github.com>
Co-authored-by: TechBrewBoss <dash@hicap.ai>
This commit is contained in:
JATMN
2026-05-31 09:03:49 +08:00
committed by GitHub
co-authored by JATMN TechBrewBoss
parent 83abfa506a
commit 4a4f379b8c
107 changed files with 6329 additions and 3118 deletions
+8 -1
View File
@@ -211,7 +211,14 @@ describe('SDK Zod schemas (type generation source)', () => {
test('PermissionModeSchema accepts valid modes', () => {
const schema = PermissionModeSchema()
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk']
const modes = [
'default',
'acceptEdits',
'bypassPermissions',
'fullAccess',
'plan',
'dontAsk',
]
for (const mode of modes) {
expect(schema.safeParse(mode).success).toBe(true)
}
+345 -2
View File
@@ -1,4 +1,5 @@
import { describe, test, expect, vi } from 'bun:test'
import { z } from 'zod/v4'
import {
buildPermissionContext,
connectSdkMcpServers,
@@ -12,6 +13,47 @@ import type { PermissionResolveDecision } from '../../src/entrypoints/sdk/permis
import { getEmptyToolPermissionContext } from '../../src/Tool.js'
import { filterToolsByDenyRules } from '../../src/tools.js'
const sdkAskTool = {
name: 'SDKAskTool',
inputSchema: z.object({}),
async checkPermissions() {
return {
behavior: 'ask',
message: 'confirm?',
updatedInput: { normalized: true },
}
},
} as any
const sdkGuidanceTool = {
name: 'SDKGuidanceTool',
inputSchema: z.object({}),
requiresUserInteraction() {
return true
},
async checkPermissions() {
return {
behavior: 'ask',
message: 'Choose an option',
updatedInput: { normalized: true },
}
},
} as any
function toolUseContextForPermissionMode(mode: string) {
return {
abortController: new AbortController(),
getAppState: () => ({
toolPermissionContext: {
...getEmptyToolPermissionContext(),
mode,
isBypassPermissionsModeAvailable:
mode === 'bypassPermissions' || mode === 'fullAccess',
},
}),
} as any
}
describe('buildPermissionContext', () => {
test('returns default mode when no permissionMode specified', () => {
const ctx = buildPermissionContext({ cwd: '/tmp' })
@@ -34,17 +76,65 @@ describe('buildPermissionContext', () => {
})
test('maps bypass-permissions mode', () => {
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'bypass-permissions' })
const ctx = buildPermissionContext({
cwd: '/tmp',
permissionMode: 'bypass-permissions',
allowDangerouslySkipPermissions: true,
})
expect(ctx.mode).toBe('bypassPermissions')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
test('maps bypassPermissions mode', () => {
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'bypassPermissions' })
const ctx = buildPermissionContext({
cwd: '/tmp',
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
})
expect(ctx.mode).toBe('bypassPermissions')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
test('maps fullAccess mode', () => {
const ctx = buildPermissionContext({
cwd: '/tmp',
permissionMode: 'fullAccess',
allowDangerouslySkipPermissions: true,
})
expect(ctx.mode).toBe('fullAccess')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
test('maps full-access mode', () => {
const ctx = buildPermissionContext({
cwd: '/tmp',
permissionMode: 'full-access',
allowDangerouslySkipPermissions: true,
})
expect(ctx.mode).toBe('fullAccess')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
test('rejects dangerous modes without allowDangerouslySkipPermissions', () => {
expect(() =>
buildPermissionContext({
cwd: '/tmp',
permissionMode: 'bypassPermissions',
}),
).toThrow(
'SDK permissionMode "bypassPermissions" requires allowDangerouslySkipPermissions: true',
)
expect(() =>
buildPermissionContext({
cwd: '/tmp',
permissionMode: 'fullAccess',
}),
).toThrow(
'SDK permissionMode "fullAccess" requires allowDangerouslySkipPermissions: true',
)
})
test('default mode does not have bypass available', () => {
const ctx = buildPermissionContext({ cwd: '/tmp' })
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
@@ -140,6 +230,23 @@ describe('createDefaultCanUseTool', () => {
expect(result.behavior).toBe('allow')
})
test('honors forced ask outside fullAccess', async () => {
const ctx = getEmptyToolPermissionContext()
const canUseTool = createDefaultCanUseTool(ctx)
const forced = { behavior: 'ask' as const, message: 'confirm?' }
const result = await canUseTool(
sdkAskTool,
{},
toolUseContextForPermissionMode('default'),
{} as any,
'default-force-ask',
forced,
)
expect(result).toBe(forced)
})
test('warning not emitted at construction time', () => {
const ctx = getEmptyToolPermissionContext()
const logger = { warn: vi.fn() }
@@ -165,6 +272,46 @@ describe('createDefaultCanUseTool', () => {
expect(logger.warn).not.toHaveBeenCalled()
})
test('fullAccess still denies by default without SDK permission callbacks', async () => {
const ctx = getEmptyToolPermissionContext()
const logger = { warn: vi.fn() }
const canUseTool = createDefaultCanUseTool(ctx, logger)
const result = await canUseTool(
{ name: 'Bash' } as any,
{ command: 'git status' },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-default-force-ask',
{
behavior: 'ask' as const,
message: 'confirm?',
updatedInput: { command: 'git status --short' },
},
)
expect(result.behavior).toBe('deny')
expect(result.message).toContain('no canUseTool or onPermissionRequest callback provided')
})
test('fullAccess remains fail-closed without callbacks', async () => {
const ctx = getEmptyToolPermissionContext()
const logger = { warn: vi.fn() }
const canUseTool = createDefaultCanUseTool(ctx, logger)
const result = await canUseTool(
sdkAskTool,
{ raw: true },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-default-no-force',
undefined,
)
expect(result.behavior).toBe('deny')
expect(result.message).toContain('no canUseTool or onPermissionRequest callback provided')
})
})
describe('createExternalCanUseTool synchronous host response', () => {
@@ -202,6 +349,202 @@ describe('createExternalCanUseTool synchronous host response', () => {
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
})
test('fullAccess still routes forced ask through host permission callbacks', async () => {
const permissionTarget = createPermissionTarget()
const onPermissionRequest = vi.fn((message: any) => {
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
pending!.resolve({ behavior: 'allow' as const })
})
const canUseTool = createExternalCanUseTool(
undefined,
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
permissionTarget,
onPermissionRequest,
undefined,
50,
'test-session-full-access',
)
const result = await canUseTool(
{ name: 'TestTool' } as any,
{ action: 'run' },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-external-force-ask',
{
behavior: 'ask' as const,
message: 'confirm?',
updatedInput: { action: 'run-fast' },
},
)
expect(result.behavior).toBe('allow')
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
expect(onPermissionRequest.mock.calls[0][0].input).toEqual({
action: 'run-fast',
})
expect(permissionTarget.pendingPermissionPrompts.size).toBe(0)
})
test('honors forced ask outside fullAccess before SDK callbacks', async () => {
const permissionTarget = createPermissionTarget()
const onPermissionRequest = vi.fn()
const userFn = vi.fn(async () => ({
behavior: 'allow' as const,
}))
const forced = { behavior: 'ask' as const, message: 'confirm?' }
const canUseTool = createExternalCanUseTool(
userFn,
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
permissionTarget,
onPermissionRequest,
undefined,
50,
'test-session-default',
)
const result = await canUseTool(
sdkAskTool,
{},
toolUseContextForPermissionMode('default'),
{} as any,
'default-external-force-ask',
forced,
)
expect(result).toBe(forced)
expect(userFn).not.toHaveBeenCalled()
expect(onPermissionRequest).not.toHaveBeenCalled()
expect(permissionTarget.pendingPermissionPrompts.size).toBe(0)
})
test('fullAccess preserves forced guidance prompts for SDK callbacks', async () => {
const permissionTarget = createPermissionTarget()
const onPermissionRequest = vi.fn((message: any) => {
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
pending!.resolve({
behavior: 'allow' as const,
updatedInput: { answer: 'option-b' },
})
})
const canUseTool = createExternalCanUseTool(
undefined,
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
permissionTarget,
onPermissionRequest,
undefined,
50,
'test-session-full-access',
)
const result = await canUseTool(
sdkGuidanceTool,
{ raw: true },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-forced-guidance',
{
behavior: 'ask' as const,
message: 'Choose an option',
updatedInput: { normalizedByHook: true },
},
)
expect(result).toMatchObject({
behavior: 'allow',
updatedInput: { answer: 'option-b' },
})
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
expect(onPermissionRequest.mock.calls[0][0].input).toEqual({
normalized: true,
})
})
test('fullAccess still respects SDK canUseTool callbacks', async () => {
const permissionTarget = createPermissionTarget()
const onPermissionRequest = vi.fn()
const userFn = vi.fn(async () => ({
behavior: 'deny' as const,
message: 'denied by host policy',
}))
const fallback = vi.fn(async () => ({
behavior: 'deny' as const,
message: 'fallback should not run',
}))
const canUseTool = createExternalCanUseTool(
userFn,
fallback,
permissionTarget,
onPermissionRequest,
undefined,
50,
'test-session-full-access',
)
const result = await canUseTool(
sdkAskTool,
{ raw: true },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-external-no-force',
undefined,
)
expect(result).toMatchObject({
behavior: 'deny',
message: 'denied by host policy',
})
expect(userFn).toHaveBeenCalledTimes(1)
expect(userFn).toHaveBeenCalledWith(
'SDKAskTool',
{ normalized: true },
{ toolUseID: 'full-access-external-no-force' },
)
expect(fallback).not.toHaveBeenCalled()
expect(onPermissionRequest).not.toHaveBeenCalled()
expect(permissionTarget.pendingPermissionPrompts.size).toBe(0)
})
test('fullAccess preserves SDK callbacks for guidance prompts', async () => {
const permissionTarget = createPermissionTarget()
const onPermissionRequest = vi.fn((message: any) => {
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
pending!.resolve({
behavior: 'allow' as const,
updatedInput: { answer: 'option-a' },
})
})
const canUseTool = createExternalCanUseTool(
undefined,
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
permissionTarget,
onPermissionRequest,
undefined,
50,
'test-session-full-access',
)
const result = await canUseTool(
sdkGuidanceTool,
{ raw: true },
toolUseContextForPermissionMode('fullAccess'),
{} as any,
'full-access-guidance',
undefined,
)
expect(result).toMatchObject({
behavior: 'allow',
updatedInput: { answer: 'option-a' },
})
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
})
test('permission request message includes uuid and session_id matching schema', async () => {
// Regression test: permission_request must match SDKMessageSchema contract
// which requires uuid and session_id fields (not optional).