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")}
+
+