diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 460fd36b9..a59cd1422 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -16,7 +16,10 @@ import { importantEnvKeysByAgent, importantFieldsFor, inferGrokMode, + applyClaudePermissionModeToConfigText, materializeClaudeHardeningFlags, + normalizeClaudePermissionMode, + readClaudePermissionMode, patchCodexConfigTomlText, patchEnvByImportantKey, patchImportantConfigText, @@ -1217,6 +1220,88 @@ 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("plan")).toBe("plan") + expect(normalizeClaudePermissionMode("dontAsk")).toBe("dontAsk") + 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).toBeUndefined() + }) + + it("round-trips plan without turning it into unset", () => { + const { configText } = applyClaudePermissionModeToConfigText( + JSON.stringify({ permissions: { defaultMode: "plan", allow: ["Bash"] } }), + "plan" + ) + const parsed = JSON.parse(configText) as { + permissions?: { defaultMode?: string; allow?: string[] } + } + expect(parsed.permissions?.defaultMode).toBe("plan") + expect(parsed.permissions?.allow).toEqual(["Bash"]) + }) + + 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 4439af108..159980dd3 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -37,6 +37,7 @@ import { } from "lucide-react" import { parse as parseTomlDocument } from "smol-toml" import { isDesktop, openUrl } from "@/lib/platform" +import { saveModeIdPreference } from "@/lib/selector-prefs-storage" import { getActiveRemoteConnectionId } from "@/lib/transport" import { toast } from "sonner" import { @@ -225,6 +226,11 @@ interface AgentDraft { claudeCustomModelOptionName: string claudeCustomModelOptionDescription: string claudeEffortLevel: ClaudeEffortLevel + // Claude Code `permissions.defaultMode` in ~/.claude/settings.json. Empty + // string = unset (leave the CLI default and CodeG's composer last-used). + // A chosen value is also written to `codeg:selector-prefs` on save so a + // stickier composer pick cannot silently win on connect. + 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, @@ -603,6 +609,103 @@ function normalizeClaudeEffortLevel(value: unknown): ClaudeEffortLevel { return "" } +// 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`. `plan` and `dontAsk` are valid disk values +// we do not offer as options; they must round-trip so the control cannot +// silently replace them with unset. +type ClaudePermissionMode = + | "" + | "default" + | "acceptEdits" + | "auto" + | "bypassPermissions" + | "plan" + | "dontAsk" + +const CLAUDE_PERMISSION_UNSET = "__codeg_claude_perm_unset__" + +const CLAUDE_PERMISSION_MODE_VALUES: ReadonlyArray< + "default" | "acceptEdits" | "auto" | "bypassPermissions" +> = ["default", "acceptEdits", "auto", "bypassPermissions"] + +function isOfferedClaudePermissionMode( + mode: ClaudePermissionMode +): mode is (typeof CLAUDE_PERMISSION_MODE_VALUES)[number] { + return (CLAUDE_PERMISSION_MODE_VALUES as readonly string[]).includes(mode) +} + +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" || + normalized === "plan" || + normalized === "dontAsk" + ) { + 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). Never writes + * `skipDangerousModePermissionPrompt`: that flag is a record of the user + * accepting Claude Code's own bypass dialog, not a behavior switch, and + * forging it from this dropdown would suppress a safety interlock outside + * codeg. Clearing the mode also leaves any existing 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 + } 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", @@ -951,6 +1054,7 @@ function extractImportantConfigValues( claudeCustomModelOptionName: string claudeCustomModelOptionDescription: string claudeEffortLevel: ClaudeEffortLevel + claudePermissionMode: ClaudePermissionMode claudeSendAttributionHeader: boolean claudeDisableNonessentialTraffic: boolean configError: string | null @@ -1000,6 +1104,8 @@ function extractImportantConfigValues( agentType === "claude_code" ? normalizeClaudeEffortLevel(config[CLAUDE_EFFORT_LEVEL_CONFIG_KEY]) : "" + 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, [ @@ -1041,6 +1147,7 @@ function extractImportantConfigValues( claudeCustomModelOptionDescription: agentType === "claude_code" ? claudeCustomModelOptionDescription : "", claudeEffortLevel, + claudePermissionMode, claudeSendAttributionHeader, claudeDisableNonessentialTraffic, configError: parseResult.error, @@ -3575,6 +3682,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { claudeCustomModelOptionDescription: important.claudeCustomModelOptionDescription, claudeEffortLevel: important.claudeEffortLevel, + claudePermissionMode: important.claudePermissionMode, claudeSendAttributionHeader: important.claudeSendAttributionHeader, claudeDisableNonessentialTraffic: important.claudeDisableNonessentialTraffic, @@ -5549,6 +5657,7 @@ export function AcpAgentSettings() { claudeCustomModelOptionDescription: important.claudeCustomModelOptionDescription, claudeEffortLevel: important.claudeEffortLevel, + claudePermissionMode: important.claudePermissionMode, claudeSendAttributionHeader: important.claudeSendAttributionHeader, claudeDisableNonessentialTraffic: important.claudeDisableNonessentialTraffic, @@ -5590,6 +5699,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] + ) + const handleClaudeEffortLevelChange = useCallback( (nextValue: ClaudeEffortLevel) => { if ( @@ -11437,6 +11574,56 @@ supports_websockets = true`} {t("claude.customModelOptionHint")}

+
+ + +

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

+