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
8 changes: 8 additions & 0 deletions packages/app/src/components/dialog-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsPermissions } from "./settings-permissions"

export const DialogSettings: Component = () => {
const language = useLanguage()
Expand Down Expand Up @@ -41,6 +42,10 @@ export const DialogSettings: Component = () => {
<Icon name="providers" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="permissions">
<Icon name="chevron-double-right" />
{language.t("settings.permissions.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
Expand All @@ -64,6 +69,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
<Tabs.Content value="permissions" class="no-scrollbar">
<SettingsPermissions />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
Expand Down
247 changes: 247 additions & 0 deletions packages/app/src/components/settings-permissions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { showToast } from "@opencode-ai/ui/toast"
import { Component, For, createMemo, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"

type PermissionAction = "allow" | "ask" | "deny"

type PermissionObject = Record<string, PermissionAction>
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
type PermissionMap = Record<string, PermissionValue>

type PermissionItem = {
id: string
title: string
description: string
}

const ACTIONS = [
{ value: "allow", label: "settings.permissions.action.allow" },
{ value: "ask", label: "settings.permissions.action.ask" },
{ value: "deny", label: "settings.permissions.action.deny" },
] as const

const ITEMS = [
{
id: "read",
title: "settings.permissions.tool.read.title",
description: "settings.permissions.tool.read.description",
},
{
id: "edit",
title: "settings.permissions.tool.edit.title",
description: "settings.permissions.tool.edit.description",
},
{
id: "glob",
title: "settings.permissions.tool.glob.title",
description: "settings.permissions.tool.glob.description",
},
{
id: "grep",
title: "settings.permissions.tool.grep.title",
description: "settings.permissions.tool.grep.description",
},
{
id: "list",
title: "settings.permissions.tool.list.title",
description: "settings.permissions.tool.list.description",
},
{
id: "bash",
title: "settings.permissions.tool.bash.title",
description: "settings.permissions.tool.bash.description",
},
{
id: "task",
title: "settings.permissions.tool.task.title",
description: "settings.permissions.tool.task.description",
},
{
id: "skill",
title: "settings.permissions.tool.skill.title",
description: "settings.permissions.tool.skill.description",
},
{
id: "lsp",
title: "settings.permissions.tool.lsp.title",
description: "settings.permissions.tool.lsp.description",
},
{
id: "todoread",
title: "settings.permissions.tool.todoread.title",
description: "settings.permissions.tool.todoread.description",
},
{
id: "todowrite",
title: "settings.permissions.tool.todowrite.title",
description: "settings.permissions.tool.todowrite.description",
},
{
id: "webfetch",
title: "settings.permissions.tool.webfetch.title",
description: "settings.permissions.tool.webfetch.description",
},
{
id: "websearch",
title: "settings.permissions.tool.websearch.title",
description: "settings.permissions.tool.websearch.description",
},
{
id: "codesearch",
title: "settings.permissions.tool.codesearch.title",
description: "settings.permissions.tool.codesearch.description",
},
{
id: "external_directory",
title: "settings.permissions.tool.external_directory.title",
description: "settings.permissions.tool.external_directory.description",
},
{
id: "doom_loop",
title: "settings.permissions.tool.doom_loop.title",
description: "settings.permissions.tool.doom_loop.description",
},
] as const

const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])

function toMap(value: unknown): PermissionMap {
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap

const action = getAction(value)
if (action) return { "*": action }

return {}
}

function getAction(value: unknown): PermissionAction | undefined {
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
return
}

function getRuleDefault(value: unknown): PermissionAction | undefined {
const action = getAction(value)
if (action) return action

if (!value || typeof value !== "object" || Array.isArray(value)) return

return getAction((value as Record<string, unknown>)["*"])
}

export const SettingsPermissions: Component = () => {
const globalSync = useGlobalSync()
const language = useLanguage()
const perms = usePermission()

const actions = createMemo(
(): Array<{ value: PermissionAction; label: string }> =>
ACTIONS.map((action) => ({
value: action.value,
label: language.t(action.label),
})),
)

const rules = createMemo(() => {
return toMap(globalSync.data.config.permission)
})

const actionFor = (id: string): PermissionAction => {
const value = rules()[id]
const direct = getRuleDefault(value)
if (direct) return direct

const wildcard = getRuleDefault(rules()["*"])
if (wildcard) return wildcard

return "allow"
}

const setPermission = async (id: string, action: PermissionAction) => {
const before = globalSync.data.config.permission
const map = toMap(before)
const existing = map[id]

const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action

const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
}

globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}

return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
</div>
</div>

<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">{language.t("command.permissions.autoaccept.enable")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-permissions-autoaccept">
<Switch checked={perms.isAutoAcceptingGlobal()} onChange={() => perms.toggleAutoAcceptGlobal()} />
</div>
</SettingsRow>
</div>
</div>

<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
<div class="border border-border-weak-base rounded-lg overflow-hidden">
<For each={ITEMS}>
{(item) => (
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
<Select
options={actions()}
current={actions().find((o) => o.value === actionFor(item.id))}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && setPermission(item.id, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
)}
</For>
</div>
</div>
</div>
</div>
)
}

interface SettingsRowProps {
title: string
description: string
children: JSX.Element
}

const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}
41 changes: 40 additions & 1 deletion packages/app/src/context/permission-auto-respond.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
import {
autoRespondsPermission,
globalAcceptKey,
isDirectoryAutoAccepting,
isGlobalAutoAccepting,
} from "./permission-auto-respond"

const session = (input: { id: string; parentID?: string }) =>
({
Expand Down Expand Up @@ -81,6 +86,26 @@ describe("autoRespondsPermission", () => {

expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})

test("falls back to global auto-accept", () => {
const sessions = [session({ id: "root" })]
const autoAccept = {
[globalAcceptKey()]: true,
}

expect(autoRespondsPermission(autoAccept, sessions, permission("root"), "/tmp/project")).toBe(true)
})

test("directory-level override takes precedence over global auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[globalAcceptKey()]: true,
[`${base64Encode(directory)}/*`]: false,
}

expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
})

describe("isDirectoryAutoAccepting", () => {
Expand All @@ -99,4 +124,18 @@ describe("isDirectoryAutoAccepting", () => {
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})

test("returns true when global key is set", () => {
expect(isDirectoryAutoAccepting({ [globalAcceptKey()]: true }, "/tmp/project")).toBe(true)
})
})

describe("isGlobalAutoAccepting", () => {
test("returns true when global key is set", () => {
expect(isGlobalAutoAccepting({ [globalAcceptKey()]: true })).toBe(true)
})

test("returns false when global key is not set", () => {
expect(isGlobalAutoAccepting({})).toBe(false)
})
})
17 changes: 15 additions & 2 deletions packages/app/src/context/permission-auto-respond.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { base64Encode } from "@opencode-ai/util/encode"

export function globalAcceptKey() {
return "*"
}

export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
Expand All @@ -12,12 +16,21 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
return (
autoAccept[key] ??
autoAccept[sessionID] ??
(directoryKey ? autoAccept[directoryKey] : undefined) ??
autoAccept[globalAcceptKey()]
)
}

export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
return autoAccept[key] ?? autoAccept[globalAcceptKey()] ?? false
}

export function isGlobalAutoAccepting(autoAccept: Record<string, boolean>) {
return autoAccept[globalAcceptKey()] ?? false
}

function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
Expand Down
Loading
Loading