From ff977e292bb4c98b8df8d5987cfa198549d76a0b Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:24:29 -0700 Subject: [PATCH 1/2] feat(claude): add permission mode control on the Agents page Claude Code had no Settings toggle for the start-of-session permission mode, unlike Grok and Cursor. Write the official permissions.defaultMode key in ~/.claude/settings.json and skip the bypass confirmation dialog when Always approve is selected. --- .../settings/acp-agent-settings.test.tsx | 71 ++++++++ .../settings/acp-agent-settings.tsx | 165 ++++++++++++++++++ src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- 12 files changed, 326 insertions(+), 10 deletions(-) diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 4532f2ec38..c6487f7f7d 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -19,7 +19,10 @@ import { importantEnvKeysByAgent, importantFieldsFor, inferGrokMode, + applyClaudePermissionModeToConfigText, materializeClaudeHardeningFlags, + normalizeClaudePermissionMode, + readClaudePermissionMode, patchCodexConfigTomlText, patchEnvByImportantKey, patchImportantConfigText, @@ -1295,6 +1298,74 @@ describe("materializeClaudeHardeningFlags — save-time toggle defaults", () => }) }) +describe("Claude permission mode — official settings.json defaultMode", () => { + it("maps the documented manual alias to default", () => { + expect(normalizeClaudePermissionMode("manual")).toBe("default") + expect(normalizeClaudePermissionMode("bypassPermissions")).toBe( + "bypassPermissions" + ) + expect(normalizeClaudePermissionMode("nope")).toBe("") + }) + + it("reads permissions.defaultMode from a nested settings object", () => { + expect( + readClaudePermissionMode({ + permissions: { defaultMode: "acceptEdits", allow: ["Bash"] }, + }) + ).toBe("acceptEdits") + expect(readClaudePermissionMode({ theme: "dark" })).toBe("") + }) + + it("sets bypass without dropping existing allow/deny rules", () => { + const base = JSON.stringify({ + theme: "dark", + permissions: { allow: ["Bash"], deny: ["Read(./.env)"] }, + }) + const { configText, recoveredFromInvalid } = + applyClaudePermissionModeToConfigText(base, "bypassPermissions") + expect(recoveredFromInvalid).toBe(false) + const parsed = JSON.parse(configText) as { + theme?: string + skipDangerousModePermissionPrompt?: boolean + permissions?: { defaultMode?: string; allow?: string[]; deny?: string[] } + } + expect(parsed.theme).toBe("dark") + expect(parsed.permissions?.defaultMode).toBe("bypassPermissions") + expect(parsed.permissions?.allow).toEqual(["Bash"]) + expect(parsed.permissions?.deny).toEqual(["Read(./.env)"]) + expect(parsed.skipDangerousModePermissionPrompt).toBe(true) + }) + + it("clears defaultMode but keeps other permission rules", () => { + const base = JSON.stringify({ + permissions: { defaultMode: "bypassPermissions", allow: ["Bash"] }, + skipDangerousModePermissionPrompt: true, + }) + const { configText } = applyClaudePermissionModeToConfigText(base, "") + const parsed = JSON.parse(configText) as { + skipDangerousModePermissionPrompt?: boolean + permissions?: { defaultMode?: string; allow?: string[] } + } + expect(parsed.permissions?.defaultMode).toBeUndefined() + expect(parsed.permissions?.allow).toEqual(["Bash"]) + expect(parsed.skipDangerousModePermissionPrompt).toBe(true) + }) + + it("drops an empty permissions object when unsetting the only key", () => { + const base = JSON.stringify({ + permissions: { defaultMode: "auto" }, + theme: "dark", + }) + const { configText } = applyClaudePermissionModeToConfigText(base, "") + const parsed = JSON.parse(configText) as { + theme?: string + permissions?: unknown + } + expect(parsed.theme).toBe("dark") + expect(parsed.permissions).toBeUndefined() + }) +}) + describe("patchCodexConfigTomlText — codeg's requires_openai_auth default", () => { /** Read `model_providers.codeg.requires_openai_auth` back out of a result. */ function authFlagOf(configTomlText: string): boolean | undefined { diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 1586960d2c..e069f9cc19 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -230,6 +230,10 @@ interface AgentDraft { claudeCustomModelOption: string claudeCustomModelOptionName: string claudeCustomModelOptionDescription: string + // Claude Code `permissions.defaultMode` in ~/.claude/settings.json. Empty + // string = unset (Claude's own default). Distinct from the per-chat + // composer dropdown: this is the session start default. + claudePermissionMode: ClaudePermissionMode // Claude Code hardening toggles (native config `env`). `claudeSendAttributionHeader` // → CLAUDE_CODE_ATTRIBUTION_HEADER (on="1"/off="0"), default off (don't send). // `claudeDisableNonessentialTraffic` → CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, @@ -632,6 +636,92 @@ const CLAUDE_ENV_FLAG_OFF = "0" const CLAUDE_SEND_ATTRIBUTION_HEADER_DEFAULT = false const CLAUDE_DISABLE_NONESSENTIAL_TRAFFIC_DEFAULT = true +// Official Claude Code permission modes written to +// `permissions.defaultMode` in ~/.claude/settings.json. Empty string means +// the key is unset (Claude picks its own start mode). `manual` is the +// documented alias of `default` (Ask every time). +type ClaudePermissionMode = + | "" + | "default" + | "acceptEdits" + | "auto" + | "bypassPermissions" + +const CLAUDE_PERMISSION_UNSET = "__codeg_claude_perm_unset__" + +const CLAUDE_PERMISSION_MODE_VALUES: ReadonlyArray< + Exclude +> = ["default", "acceptEdits", "auto", "bypassPermissions"] + +export function normalizeClaudePermissionMode( + value: unknown +): ClaudePermissionMode { + if (typeof value !== "string") return "" + const normalized = value.trim() + if (normalized === "manual") return "default" + if ( + normalized === "default" || + normalized === "acceptEdits" || + normalized === "auto" || + normalized === "bypassPermissions" + ) { + return normalized + } + return "" +} + +export function readClaudePermissionMode( + config: Record +): ClaudePermissionMode { + const perms = config.permissions + if (!perms || typeof perms !== "object" || Array.isArray(perms)) return "" + return normalizeClaudePermissionMode( + (perms as Record).defaultMode + ) +} + +/** + * Write or clear `permissions.defaultMode` in Claude's native settings.json + * text, preserving other permission rules (allow/deny/ask). Setting + * `bypassPermissions` also stamps `skipDangerousModePermissionPrompt` so + * Claude does not pop the extra "are you sure" dialog. Clearing the mode + * leaves that skip flag alone. Pure — shared by the settings handler and tests. + */ +export function applyClaudePermissionModeToConfigText( + configText: string, + mode: ClaudePermissionMode +): { configText: string; recoveredFromInvalid: boolean } { + const parseResult = parseConfigJsonText(configText) + const config: Record = parseResult.error + ? {} + : { ...parseResult.config } + const existingPerms = + config.permissions && + typeof config.permissions === "object" && + !Array.isArray(config.permissions) + ? { ...(config.permissions as Record) } + : {} + if (mode) { + existingPerms.defaultMode = mode + config.permissions = existingPerms + if (mode === "bypassPermissions") { + config.skipDangerousModePermissionPrompt = true + } + } else { + delete existingPerms.defaultMode + if (Object.keys(existingPerms).length === 0) { + delete config.permissions + } else { + config.permissions = existingPerms + } + } + return { + configText: + Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2), + recoveredFromInvalid: Boolean(parseResult.error), + } +} + const GEMINI_AUTH_MODES = [ "custom", "login_google", @@ -979,6 +1069,7 @@ function extractImportantConfigValues( claudeCustomModelOption: string claudeCustomModelOptionName: string claudeCustomModelOptionDescription: string + claudePermissionMode: ClaudePermissionMode claudeSendAttributionHeader: boolean claudeDisableNonessentialTraffic: boolean configError: string | null @@ -1024,6 +1115,9 @@ function extractImportantConfigValues( CLAUDE_MODEL_ENV_KEYS.claudeCustomModelOptionDescription, ]) + const claudePermissionMode: ClaudePermissionMode = + agentType === "claude_code" ? readClaudePermissionMode(config) : "" + // Present in env → on iff value is "1"; absent → the toggle's default. const attributionRaw = findEnvValue(mergedEnv, [ CLAUDE_ATTRIBUTION_HEADER_ENV_KEY, @@ -1063,6 +1157,7 @@ function extractImportantConfigValues( agentType === "claude_code" ? claudeCustomModelOptionName : "", claudeCustomModelOptionDescription: agentType === "claude_code" ? claudeCustomModelOptionDescription : "", + claudePermissionMode, claudeSendAttributionHeader, claudeDisableNonessentialTraffic, configError: parseResult.error, @@ -3722,6 +3817,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { claudeCustomModelOptionName: important.claudeCustomModelOptionName, claudeCustomModelOptionDescription: important.claudeCustomModelOptionDescription, + claudePermissionMode: important.claudePermissionMode, claudeSendAttributionHeader: important.claudeSendAttributionHeader, claudeDisableNonessentialTraffic: important.claudeDisableNonessentialTraffic, @@ -5729,6 +5825,7 @@ export function AcpAgentSettings() { claudeCustomModelOptionName: important.claudeCustomModelOptionName, claudeCustomModelOptionDescription: important.claudeCustomModelOptionDescription, + claudePermissionMode: important.claudePermissionMode, claudeSendAttributionHeader: important.claudeSendAttributionHeader, claudeDisableNonessentialTraffic: important.claudeDisableNonessentialTraffic, @@ -5770,6 +5867,34 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, t, updateSelectedDraft] ) + const handleClaudePermissionModeChange = useCallback( + (nextValue: ClaudePermissionMode) => { + if ( + !selectedAgent || + !selectedDraft || + selectedAgent.agent_type !== "claude_code" + ) + return + const next = applyClaudePermissionModeToConfigText( + selectedDraft.configText, + nextValue + ) + if (next.recoveredFromInvalid) { + toast.warning(t("warnings.nativeJsonRecoveredStructured")) + } + setConfigErrors((prev) => ({ + ...prev, + [selectedAgent.agent_type]: null, + })) + updateSelectedDraft((current) => ({ + ...current, + claudePermissionMode: nextValue, + configText: next.configText, + })) + }, + [selectedAgent, selectedDraft, t, updateSelectedDraft] + ) + // Toggle a Claude Code hardening flag: write the explicit "1"/"0" value into // the native config's `env` (and the DB env overlay in lockstep). const handleClaudeEnvFlagChange = useCallback( @@ -11689,6 +11814,46 @@ supports_websockets = true`} {t("claude.customModelOptionHint")}

+
+ + +

+ {t("claude.permissionModeHint")} +

+