Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/components/settings/acp-agent-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
importantEnvKeysByAgent,
importantFieldsFor,
inferGrokMode,
applyClaudePermissionModeToConfigText,
materializeClaudeHardeningFlags,
normalizeClaudePermissionMode,
readClaudePermissionMode,
patchCodexConfigTomlText,
patchEnvByImportantKey,
patchImportantConfigText,
Expand Down Expand Up @@ -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 {
Expand Down
202 changes: 202 additions & 0 deletions src/components/settings/acp-agent-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>
): ClaudePermissionMode {
const perms = config.permissions
if (!perms || typeof perms !== "object" || Array.isArray(perms)) return ""
return normalizeClaudePermissionMode(
(perms as Record<string, unknown>).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<string, unknown> = parseResult.error
? {}
: { ...parseResult.config }
const existingPerms =
config.permissions &&
typeof config.permissions === "object" &&
!Array.isArray(config.permissions)
? { ...(config.permissions as Record<string, unknown>) }
: {}
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",
Expand Down Expand Up @@ -951,6 +1054,7 @@ function extractImportantConfigValues(
claudeCustomModelOptionName: string
claudeCustomModelOptionDescription: string
claudeEffortLevel: ClaudeEffortLevel
claudePermissionMode: ClaudePermissionMode
claudeSendAttributionHeader: boolean
claudeDisableNonessentialTraffic: boolean
configError: string | null
Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -1041,6 +1147,7 @@ function extractImportantConfigValues(
claudeCustomModelOptionDescription:
agentType === "claude_code" ? claudeCustomModelOptionDescription : "",
claudeEffortLevel,
claudePermissionMode,
claudeSendAttributionHeader,
claudeDisableNonessentialTraffic,
configError: parseResult.error,
Expand Down Expand Up @@ -3575,6 +3682,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft {
claudeCustomModelOptionDescription:
important.claudeCustomModelOptionDescription,
claudeEffortLevel: important.claudeEffortLevel,
claudePermissionMode: important.claudePermissionMode,
claudeSendAttributionHeader: important.claudeSendAttributionHeader,
claudeDisableNonessentialTraffic:
important.claudeDisableNonessentialTraffic,
Expand Down Expand Up @@ -5549,6 +5657,7 @@ export function AcpAgentSettings() {
claudeCustomModelOptionDescription:
important.claudeCustomModelOptionDescription,
claudeEffortLevel: important.claudeEffortLevel,
claudePermissionMode: important.claudePermissionMode,
claudeSendAttributionHeader: important.claudeSendAttributionHeader,
claudeDisableNonessentialTraffic:
important.claudeDisableNonessentialTraffic,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -11437,6 +11574,56 @@ supports_websockets = true`}
{t("claude.customModelOptionHint")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.permissionModeLabel")}
</label>
<Select
value={
selectedDraft.claudePermissionMode ||
CLAUDE_PERMISSION_UNSET
}
onValueChange={(nextValue) => {
handleClaudePermissionModeChange(
nextValue === CLAUDE_PERMISSION_UNSET
? ""
: (nextValue as ClaudePermissionMode)
)
}}
>
<SelectTrigger
className="w-full"
aria-label={t("claude.permissionModeAria")}
>
<SelectValue
placeholder={t("claude.permissionUnset")}
/>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value={CLAUDE_PERMISSION_UNSET}>
{t("claude.permissionUnset")}
</SelectItem>
{CLAUDE_PERMISSION_MODE_VALUES.map((value) => (
<SelectItem key={value} value={value}>
{t(`claude.permission_${value}`)}
</SelectItem>
))}
{selectedDraft.claudePermissionMode &&
!isOfferedClaudePermissionMode(
selectedDraft.claudePermissionMode
) ? (
<SelectItem
value={selectedDraft.claudePermissionMode}
>
{selectedDraft.claudePermissionMode}
</SelectItem>
) : null}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{t("claude.permissionModeHint")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.effortLevel")}
Expand Down Expand Up @@ -11619,6 +11806,21 @@ supports_websockets = true`}
)
)
.then(() => {
// A chosen Claude default must also become CodeG's
// new-chat composer default. Connect applies
// selector-prefs after the adapter reads
// settings.json, so without this write-through a
// stickier last-used mode silently wins. Unset
// leaves the composer last-used alone.
if (
selectedAgent.agent_type === "claude_code" &&
selectedDraft.claudePermissionMode
) {
saveModeIdPreference(
"claude_code",
selectedDraft.claudePermissionMode
)
}
// Reflect the provider-authoritative rewrite AND the
// materialized hardening flags in the editors so the
// textareas don't show stale values until reload —
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,14 @@
"effortLevel_medium": "متوسط",
"effortLevel_high": "مرتفع",
"effortLevel_xhigh": "مرتفع جداً",
"permissionModeLabel": "وضع الأذونات",
"permissionModeHint": "يكتب permissions.defaultMode لجلسات CLI الجديدة. الوضع المختار يُحفظ أيضاً كافتراضي composer في CodeG حتى لا يفوز اختيار دردشة سابق بصمت عند الاتصال. استخدام الافتراضي يُبقي آخر قيمة في composer كما هي.",
"permissionModeAria": "وضع أذونات Claude Code",
"permissionUnset": "استخدام الافتراضي",
"permission_default": "يدوي",
"permission_acceptEdits": "قبول التعديلات",
"permission_auto": "تلقائي",
"permission_bypassPermissions": "تجاوز الأذونات",
"sendAttributionHeader": "إرسال معرّف الإسناد/الفوترة إلى الواجهة",
"sendAttributionHeaderAria": "إرسال معرّف الإسناد/الفوترة الخاص بـ Claude Code إلى الواجهة",
"disableNonessentialTraffic": "تعطيل القياس عن بُعد أو طلبات الشبكة غير الضرورية",
Expand Down
Loading
Loading