import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Icon } from '@/components/icon/Icon'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; import { useAgentsStore, getConfigDirectory, type AgentWithExtras, } from '@/stores/useAgentsStore'; import { SettingsSection, SettingsChipGroup, SETTINGS_FIELD_LABEL_CLASS, SETTINGS_HELPER_CLASS, } from '@/components/sections/shared/SettingsSection'; import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; import { ACTIONS, cloneModel, emptyModel, isAction, modelsEqual, parsePermissionConfig, serializePermissionModel, type Action, type EffectiveRule, type PermissionModel, } from './agentPermissionModel'; /** * Source-of-truth permissions editor. * * This component edits EXACTLY the agent's own `permission` map as stored in * its markdown frontmatter / opencode.json entry — never the resolved rules * that `/agent` returns (those already include global config and one-off * session grants, and writing them back is what used to corrupt configs). * * - "Inherit" means the key is absent from the agent's config; the effective * action (from the resolved view) is shown as a hint. * - Saving PATCHes only `{ permission }`, and the server writes it verbatim. */ /** * Permission keys that exist beyond plain tool ids (virtual capabilities). * Shown so they are discoverable; nothing is written unless set explicitly. */ const VIRTUAL_PERMISSION_KEYS = [ 'edit', 'external_directory', 'doom_loop', 'plan_enter', 'plan_exit', ] as const; /** * Keys where opencode matches pattern rules (per docs: these accept either a * bare action or a pattern map). Everything else is action-only — the pattern * UI is hidden unless the config already contains patterns for the key. */ const PATTERN_CAPABLE_KEYS = new Set([ 'read', 'edit', 'glob', 'grep', 'list', 'bash', 'task', 'external_directory', 'lsp', 'skill', ]); /** Tool ids folded into broader permission keys — never shown standalone. */ const FOLDED_TOOL_IDS = new Set(['write', 'patch', 'apply_patch', 'multiedit', 'invalid']); const formatKeyLabel = (key: string): string => key .split(/[_-]/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); interface AgentPermissionsEditorProps { agent: AgentWithExtras; } export const AgentPermissionsEditor: React.FC = ({ agent }) => { const { t } = useI18n(); const updateAgent = useAgentsStore((state) => state.updateAgent); const [baseline, setBaseline] = React.useState(emptyModel); const [model, setModel] = React.useState(emptyModel); const [isLoading, setIsLoading] = React.useState(true); const [loadFailed, setLoadFailed] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const [expandedKeys, setExpandedKeys] = React.useState>({}); const [toolIds, setToolIds] = React.useState([]); const [customKeyDraft, setCustomKeyDraft] = React.useState(''); const [reloadToken, setReloadToken] = React.useState(0); const agentName = agent.name; // --- Load the SOURCE permission map (the agent's own config file). --- React.useEffect(() => { let cancelled = false; setIsLoading(true); setLoadFailed(false); void (async () => { try { const directory = getConfigDirectory(); const query = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, { headers: { 'Cache-Control': 'no-cache', ...(directory ? { 'x-opencode-directory': directory } : {}), }, }); if (!response.ok) throw new Error(String(response.status)); const data = (await response.json().catch(() => null)) as { config?: { permission?: unknown } } | null; if (cancelled) return; const parsed = parsePermissionConfig(data?.config?.permission); setBaseline(cloneModel(parsed)); setModel(parsed); } catch { if (!cancelled) setLoadFailed(true); } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; }, [agentName, reloadToken]); // --- Known tool ids for the key list (display only). --- React.useEffect(() => { let cancelled = false; void (async () => { try { const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() }); if (!cancelled && Array.isArray(ids)) { setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id))); } } catch { // tool ids are additive display data — the editor works without them } })(); return () => { cancelled = true; }; }, [agentName]); // --- Effective rules from the resolved view (read-only hints). --- const effectiveRules = React.useMemo(() => { const raw = (agent as { permission?: unknown }).permission; if (!Array.isArray(raw)) return []; const rules: EffectiveRule[] = []; for (const entry of raw) { if (!entry || typeof entry !== 'object') continue; const { permission, pattern, action } = entry as Record; if (typeof permission === 'string' && typeof pattern === 'string' && isAction(action)) { rules.push({ permission, pattern, action }); } } return rules; }, [agent]); const effectiveFor = React.useCallback((key: string): Action | null => { const exact = effectiveRules.find((rule) => rule.permission === key && rule.pattern === '*'); if (exact) return exact.action; const wildcard = effectiveRules.find((rule) => rule.permission === '*' && rule.pattern === '*'); return wildcard ? wildcard.action : null; }, [effectiveRules]); /** Session/runtime-granted rules that are NOT part of the saved config. */ const runtimeRulesFor = React.useCallback((key: string): EffectiveRule[] => { const saved = model.keys[key]?.patterns ?? []; const savedPatterns = new Set(saved.map((rule) => rule.pattern)); return effectiveRules.filter( (rule) => rule.permission === key && rule.pattern !== '*' && !savedPatterns.has(rule.pattern), ); }, [effectiveRules, model.keys]); // --- Displayed key list: tools + virtual keys + anything set in the config. --- const displayKeys = React.useMemo(() => { const keys = new Set(); for (const id of toolIds) keys.add(id); for (const key of VIRTUAL_PERMISSION_KEYS) keys.add(key); for (const key of Object.keys(model.keys)) keys.add(key); // `edit` covers write/edit/apply_patch — the folded ids never show. for (const folded of FOLDED_TOOL_IDS) keys.delete(folded); return Array.from(keys).sort((a, b) => a.localeCompare(b)); }, [toolIds, model.keys]); const isDirty = React.useMemo(() => !modelsEqual(model, baseline), [model, baseline]); // --- Mutators --- const setGlobal = (action: Action | null) => { setModel((current) => ({ ...current, global: action })); }; const setKeyAction = (key: string, action: Action | null) => { setModel((current) => { const next = cloneModel(current); const state = next.keys[key] ?? { action: null, patterns: [] }; state.action = action; if (state.action === null && state.patterns.length === 0) { delete next.keys[key]; } else { next.keys[key] = state; } return next; }); }; const setPattern = (key: string, index: number, pattern: string, action: Action) => { setModel((current) => { const next = cloneModel(current); const state = next.keys[key] ?? { action: null, patterns: [] }; state.patterns[index] = { pattern, action }; next.keys[key] = state; return next; }); }; const addPattern = (key: string) => { setModel((current) => { const next = cloneModel(current); const state = next.keys[key] ?? { action: null, patterns: [] }; state.patterns.push({ pattern: '', action: 'allow' }); next.keys[key] = state; return next; }); setExpandedKeys((current) => ({ ...current, [key]: true })); }; const removePattern = (key: string, index: number) => { setModel((current) => { const next = cloneModel(current); const state = next.keys[key]; if (!state) return current; state.patterns.splice(index, 1); if (state.action === null && state.patterns.length === 0) { delete next.keys[key]; } return next; }); }; const addCustomKey = () => { const key = customKeyDraft.trim(); if (!key || key === '*') return; setModel((current) => { if (current.keys[key]) return current; const next = cloneModel(current); next.keys[key] = { action: 'ask', patterns: [] }; return next; }); setExpandedKeys((current) => ({ ...current, [key]: true })); setCustomKeyDraft(''); }; const handleSave = async () => { setIsSaving(true); try { const permission = serializePermissionModel(model); const result = await updateAgent(agentName, { permission }); if (result.ok) { setBaseline(cloneModel(model)); toast.success( result.requiresManualRestart ? t('settings.agents.page.permissionsEditor.toast.savedRestartRequired') : t('settings.agents.page.permissionsEditor.toast.saved'), ); } else { toast.error(t('settings.agents.page.permissionsEditor.toast.saveFailed')); } } finally { setIsSaving(false); } }; const handleDiscard = () => { setModel(cloneModel(baseline)); }; const actionLabel = (action: Action): string => t( action === 'allow' ? 'settings.agents.page.permissionsEditor.action.allow' : action === 'ask' ? 'settings.agents.page.permissionsEditor.action.ask' : 'settings.agents.page.permissionsEditor.action.deny', ); const inheritLabel = t('settings.agents.page.permissionsEditor.action.inherit'); const defaultChipLabel = t('settings.agents.page.permissionsEditor.action.default'); const chipOptions = (unsetLabel?: string) => [ ...(unsetLabel ? [{ value: 'inherit', label: unsetLabel }] : []), ...ACTIONS.map((action) => ({ value: action, label: actionLabel(action) })), ]; const renderActionChips = ( value: Action | null, onChange: (action: Action | null) => void, ariaLabel: string, unsetLabel: string = inheritLabel, ) => ( onChange(next === 'inherit' ? null : (next as Action))} aria-label={ariaLabel} /> ); if (isLoading) { return (

{t('common.loading')}

); } if (loadFailed) { return (

{t('settings.agents.page.permissionsEditor.state.loadFailed')}

); } return ( ) : undefined} contentClassName="space-y-4" > {/* Agent default (the `*` key) */}
{t('settings.agents.page.permissionsEditor.defaultLabel')} {t('settings.agents.page.permissionsEditor.defaultInfo')} {model.global === null && ( {t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effectiveFor('*') ?? 'allow') })} )}
{renderActionChips(model.global, setGlobal, t('settings.agents.page.permissionsEditor.defaultAria'), defaultChipLabel)}
{displayKeys.map((key) => { const state = model.keys[key] ?? { action: null, patterns: [] }; const runtimeRules = runtimeRulesFor(key); const supportsPatterns = PATTERN_CAPABLE_KEYS.has(key) || state.patterns.length > 0; const hasDetails = supportsPatterns || runtimeRules.length > 0; const isExpanded = expandedKeys[key] === true; const effective = effectiveFor(key); return (
{renderActionChips( state.action, (action) => setKeyAction(key, action), t('settings.agents.page.permissionsEditor.keyAria', { key }), )}
{isExpanded && hasDetails && (
{state.patterns.map((rule, index) => (
setPattern(key, index, event.target.value, rule.action)} placeholder={t('settings.agents.page.permissionsEditor.patternPlaceholder')} className="h-8 w-full max-w-[24rem] min-w-0 flex-1 font-mono text-xs" /> setPattern(key, index, rule.pattern, next as Action)} aria-label={t('settings.agents.page.permissionsEditor.patternActionAria', { key })} />
))} {supportsPatterns && ( )} {runtimeRules.length > 0 && (
{t('settings.agents.page.permissionsEditor.sessionRulesTitle')} {t('settings.agents.page.permissionsEditor.sessionRulesInfo')}
{runtimeRules.map((rule) => (
{rule.pattern} {actionLabel(rule.action)}
))}
)}
)}
); })}
{/* Custom permission key */}
setCustomKeyDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCustomKey(); } }} placeholder={t('settings.agents.page.permissionsEditor.customKeyPlaceholder')} className="h-8 w-full max-w-[16rem] font-mono text-xs" />
); };