import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; const DEFAULT_PRIORITY = 999999; const ACTIONS = ['require', 'allow', 'deny']; const ACTION_STYLE = { require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' }, allow: { icon: 'bi-check-circle', label: 'Allow', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' }, deny: { icon: 'bi-slash-circle', label: 'Deny', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' }, }; const CATEGORY_LABELS = { filesystem: 'File System', shell: 'Shell', subagent: 'Agents', introspection: 'Introspection', config: 'Config', // Tools injected dynamically outside the ToolRegistry (interface/plugin/ // provider tools), surfaced via runtime discovery — see docs/approval. dynamic: 'Dynamic', }; const CATEGORY_ORDER = [ 'File System', 'Shell', 'Agents', 'Introspection', 'Config', 'Dynamic', ]; // File System permission model. Each path row maps to exactly one approval rule via a // synthetic `@fs_*` tool_pattern token (understood by the backend matcher). A single // selector collapses the (access-class × action) axes into the mental model from the // mockup: Allow read / Allow write / Deny / Require. const FS_ACCESS = { allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' }, allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' }, deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' }, require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' }, }; // Priority band for the settable "Default" row (below specific fs path rules, above the // global `*` catch-all at 999999). const FS_DEFAULT_PRIORITY = 900; export class ApprovalRulesPage extends LightElement { static properties = { _open: { state: true }, _rules: { state: true }, _tools: { state: true }, _error: { state: true }, _selectedGroup: { state: true }, _editingId: { state: true }, _formMode: { state: true }, // 'override' | 'lowprio' | null _form: { state: true }, _toolFilter: { state: true }, _saving: { state: true }, _openSections: { state: true }, // Set _overrideOpen: { state: true }, _lowPrioOpen: { state: true }, _toolSaving: { state: true }, // Set _fsOpen: { state: true }, _fsNewPath: { state: true }, _fsNewAccess: { state: true }, _fsSaving: { state: true }, // Set }; constructor() { super(); this._open = false; this._rules = []; this._tools = null; this._error = null; this._selectedGroup = null; this._editingId = null; this._formMode = null; this._form = this._emptyForm(null); this._toolFilter = ''; this._saving = false; this._openSections = new Set(); this._overrideOpen = false; this._lowPrioOpen = false; this._toolSaving = new Set(); this._fsOpen = true; this._fsNewPath = ''; this._fsNewAccess = 'allow_read'; this._fsSaving = new Set(); } _emptyForm(mode) { const priority = mode === 'override' ? -10 : 100; return { tool_pattern: '', path_pattern: '', action: 'require', priority, agent_id: '', source: '', note: '' }; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { if (e.detail.page !== 'approval') { this._open = false; this.style.display = 'none'; } }); window.addEventListener('approval-navigate', async (e) => { if (e.detail.group === null) { this._open = false; this.style.display = 'none'; return; } this._open = true; this.style.display = 'flex'; this._selectedGroup = e.detail.group; this._editingId = null; this._formMode = null; this._openSections = new Set(); this._overrideOpen = false; this._lowPrioOpen = false; this._fsOpen = true; this._fsNewPath = ''; this._fsSaving = new Set(); await this._load(); }); } async _load() { this._error = null; try { const [rulesRes, toolsRes] = await Promise.all([ fetch('/api/approval/rules'), fetch('/api/approval/tools'), ]); if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`); if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`); this._rules = await rulesRes.json(); this._tools = await toolsRes.json(); } catch (e) { this._error = e.message; } } _rulesForGroup(groupId) { return this._rules.filter(r => (r.group_id ?? 'default') === groupId); } // ── Rule classification ──────────────────────────────────────────────────────── _isSimpleRule(r) { return !r.tool_pattern.includes('*') && (r.path_pattern == null || r.path_pattern === '') && (r.agent_id == null || r.agent_id === '') && (r.source == null || r.source === '') && Number(r.priority) === 0; } _isDefaultRule(r) { return r.tool_pattern === '*' && (r.path_pattern == null || r.path_pattern === '') && (r.agent_id == null || r.agent_id === '') && (r.source == null || r.source === '') && Number(r.priority) === DEFAULT_PRIORITY; } // File System rules (`@fs_*` tool_pattern) are managed by their own panel, so keep // them out of the override/low-priority/default buckets and the per-tool matrix. _isFsRule(r) { return typeof r.tool_pattern === 'string' && r.tool_pattern.startsWith('@fs'); } _buckets(groupId) { const all = this._rules.filter(r => (r.group_id ?? 'default') === groupId && !this._isFsRule(r)); return { overrides: all.filter(r => Number(r.priority) < 0), lowPrio: all.filter(r => !this._isDefaultRule(r) && !this._isSimpleRule(r) && Number(r.priority) >= 0), defRule: all.find(r => this._isDefaultRule(r)) ?? null, }; } _getSimpleRule(toolName, groupId) { return this._rules.find(r => this._isSimpleRule(r) && r.tool_pattern === toolName && (r.group_id ?? 'default') === groupId ) ?? null; } _getToolAction(toolName) { return this._getSimpleRule(toolName, this._selectedGroup.id)?.action ?? null; } // ── Tool action CRUD ───────────────────────────────────────────────────────── async _setToolAction(toolName, action) { const existing = this._getSimpleRule(toolName, this._selectedGroup.id); this._toolSaving = new Set([...this._toolSaving, toolName]); this._error = null; try { if (action === null) { if (existing) { const res = await fetch(`/api/approval/rules/${existing.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); } } else if (existing) { const res = await fetch(`/api/approval/rules/${existing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: existing.tool_pattern, path_pattern: existing.path_pattern ?? null, action, priority: 0, agent_id: existing.agent_id ?? null, source: existing.source ?? null, note: existing.note ?? null, group_id: existing.group_id ?? 'default', }), }); if (!res.ok) throw new Error(await res.text()); } else { const res = await fetch('/api/approval/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: toolName, action, priority: 0, group_id: this._selectedGroup.id, }), }); if (!res.ok) throw new Error(await res.text()); } await this._load(); } catch (e) { this._error = e.message; } finally { this._toolSaving = new Set([...this._toolSaving].filter(n => n !== toolName)); } } // ── Default action CRUD ────────────────────────────────────────────────────── _getDefaultAction() { return this._buckets(this._selectedGroup?.id ?? 'default').defRule?.action ?? null; } async _setDefaultAction(action) { const existing = this._buckets(this._selectedGroup.id).defRule; this._error = null; try { if (action === null) { if (existing) { const res = await fetch(`/api/approval/rules/${existing.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); } } else if (existing) { const res = await fetch(`/api/approval/rules/${existing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: '*', action, priority: DEFAULT_PRIORITY, group_id: this._selectedGroup.id, }), }); if (!res.ok) throw new Error(await res.text()); } else { const res = await fetch('/api/approval/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: '*', action, priority: DEFAULT_PRIORITY, group_id: this._selectedGroup.id, }), }); if (!res.ok) throw new Error(await res.text()); } await this._load(); } catch (e) { this._error = e.message; } } // ── File System CRUD ────────────────────────────────────────────────────────── // Path rules: @fs_* rules that carry a path_pattern, ordered by evaluation priority. _fsPathRules(groupId) { return this._rules .filter(r => (r.group_id ?? 'default') === groupId && this._isFsRule(r) && r.path_pattern != null && r.path_pattern !== '') .sort((a, b) => Number(a.priority) - Number(b.priority) || a.id - b.id); } // The optional settable fallback: an @fs_* rule with no path_pattern. _fsDefaultRule(groupId) { return this._rules.find(r => (r.group_id ?? 'default') === groupId && this._isFsRule(r) && (r.path_pattern == null || r.path_pattern === '') ) ?? null; } // Round-trip a stored rule back to a FS_ACCESS selector key. _fsAccessValue(r) { if (r.action === 'deny') return 'deny'; if (r.action === 'require') return 'require'; if (r.tool_pattern === '@fs_read') return 'allow_read'; return 'allow_write'; } // Strip `./`, leading slashes and any trailing `/` or `/*` — the caller appends `/*`. _normalizeFsPath(raw) { return (raw ?? '') .trim() .replace(/^\.\//, '') .replace(/^\/+/, '') .replace(/\/\*$/, '') .replace(/\/+$/, ''); } // Display form of a rule's path (`memory/*` → `memory/`). _fsDisplayPath(r) { const pp = r.path_pattern ?? ''; return pp.endsWith('/*') ? `${pp.slice(0, -2)}/` : pp; } // Deeper paths evaluate first; depth-1 lands on 5 to match the seeded defaults. _fsPriorityForPath(clean) { const depth = clean.split('/').filter(Boolean).length; return Math.max(1, 6 - depth); } async _addFsRule() { const clean = this._normalizeFsPath(this._fsNewPath); if (!clean) { this._error = 'Enter a directory path.'; return; } const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read; this._fsSaving = new Set([...this._fsSaving, 'new']); this._error = null; try { const res = await fetch('/api/approval/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: access.tool_pattern, path_pattern: `${clean}/*`, action: access.action, priority: this._fsPriorityForPath(clean), group_id: this._selectedGroup.id, note: 'file system', }), }); if (!res.ok) throw new Error(await res.text()); this._fsNewPath = ''; await this._load(); } catch (e) { this._error = e.message; } finally { this._fsSaving = new Set([...this._fsSaving].filter(x => x !== 'new')); } } async _setFsAccess(rule, accessValue) { const access = FS_ACCESS[accessValue]; if (!access) return; this._fsSaving = new Set([...this._fsSaving, rule.id]); this._error = null; try { const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool_pattern: access.tool_pattern, path_pattern: rule.path_pattern, action: access.action, priority: rule.priority, group_id: rule.group_id ?? 'default', note: rule.note ?? 'file system', }), }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } finally { this._fsSaving = new Set([...this._fsSaving].filter(x => x !== rule.id)); } } async _deleteFsRule(rule) { if (!confirm(`Remove File System rule for "${this._fsDisplayPath(rule)}"?`)) return; this._fsSaving = new Set([...this._fsSaving, rule.id]); this._error = null; try { const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } finally { this._fsSaving = new Set([...this._fsSaving].filter(x => x !== rule.id)); } } async _setFsDefault(accessValue) { const existing = this._fsDefaultRule(this._selectedGroup.id); this._error = null; try { if (accessValue === null) { if (existing) { const res = await fetch(`/api/approval/rules/${existing.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); } } else { const access = FS_ACCESS[accessValue]; const body = { tool_pattern: access.tool_pattern, path_pattern: null, action: access.action, priority: FS_DEFAULT_PRIORITY, group_id: this._selectedGroup.id, note: 'file system default', }; const url = existing ? `/api/approval/rules/${existing.id}` : '/api/approval/rules'; const res = await fetch(url, { method: existing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); } await this._load(); } catch (e) { this._error = e.message; } } // ── Section toggling ───────────────────────────────────────────────────────── _toggleSection(id) { const s = new Set(this._openSections); if (s.has(id)) s.delete(id); else s.add(id); this._openSections = s; } // ── Tool grouping ───────────────────────────────────────────────────────────── _groupedTools() { if (!this._tools) return []; const map = new Map(); const metaMap = new Map(); // category key → { description } for (const t of this._tools.built_in) { // Filesystem tools are gated by path in the File System panel, not per-tool here. if (t.category === 'filesystem') continue; const cat = t.category ? (CATEGORY_LABELS[t.category] ?? t.category) : 'Other'; if (!map.has(cat)) map.set(cat, []); map.get(cat).push(t); } const servers = this._tools.mcp_servers ?? {}; for (const t of this._tools.mcp) { const serverId = t.server ?? t.name; const meta = servers[serverId] ?? {}; const key = `MCP · ${meta.friendly_name ?? serverId}`; if (!map.has(key)) { map.set(key, []); if (meta.description) metaMap.set(key, meta.description); } map.get(key).push(t); } const result = []; for (const cat of CATEGORY_ORDER) { if (map.has(cat)) result.push([cat, map.get(cat), null]); } for (const [key, tools] of map.entries()) { if (!CATEGORY_ORDER.includes(key) && key !== 'Other') result.push([key, tools, metaMap.get(key) ?? null]); } if (map.has('Other')) result.push(['Other', map.get('Other'), null]); return result; } // ── Override / LowPrio rule management ──────────────────────────────────────── _startNew(mode) { this._editingId = 'new'; this._formMode = mode; this._form = this._emptyForm(mode); this._toolFilter = ''; if (mode === 'override') this._overrideOpen = true; if (mode === 'lowprio') this._lowPrioOpen = true; } _startEdit(rule) { this._editingId = rule.id; this._formMode = Number(rule.priority) < 0 ? 'override' : 'lowprio'; this._toolFilter = ''; this._form = { tool_pattern: rule.tool_pattern, path_pattern: rule.path_pattern ?? '', action: rule.action, priority: rule.priority, agent_id: rule.agent_id ?? '', source: rule.source ?? '', note: rule.note ?? '', }; if (this._formMode === 'override') this._overrideOpen = true; if (this._formMode === 'lowprio') this._lowPrioOpen = true; } _cancelEdit() { this._editingId = null; this._formMode = null; this._toolFilter = ''; } _patch(field, value) { this._form = { ...this._form, [field]: value }; } _selectTool(name) { this._form = { ...this._form, tool_pattern: name }; } async _save() { if (!this._form.tool_pattern.trim()) { this._error = 'Tool pattern is required.'; return; } const p = Number(this._form.priority); if (this._formMode === 'override' && p >= 0) { this._error = 'Override rules must have priority < 0.'; return; } if (this._formMode === 'lowprio' && (p <= 0 || p >= DEFAULT_PRIORITY)) { this._error = `Low priority rules must have priority between 1 and ${DEFAULT_PRIORITY - 1}.`; return; } this._saving = true; this._error = null; try { const body = { tool_pattern: this._form.tool_pattern.trim(), path_pattern: this._form.path_pattern.trim() || null, action: this._form.action, priority: p, agent_id: this._form.agent_id.trim() || null, source: this._form.source.trim() || null, note: this._form.note.trim() || null, group_id: this._selectedGroup?.id ?? 'default', }; const isNew = this._editingId === 'new'; const url = isNew ? '/api/approval/rules' : `/api/approval/rules/${this._editingId}`; const res = await fetch(url, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); this._editingId = null; this._formMode = null; await this._load(); } catch (e) { this._error = e.message; } finally { this._saving = false; } } async _delete(rule) { if (!confirm(`Delete rule for "${rule.tool_pattern}"?`)) return; try { const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Back to groups ──────────────────────────────────────────────────────────── _goBack() { this._open = false; this.style.display = 'none'; window.location.hash = 'approval'; window.dispatchEvent(new CustomEvent('approval-navigate', { detail: { group: null } })); } // ── Tool picker ─────────────────────────────────────────────────────────────── _renderToolPicker() { if (!this._tools) return nothing; const q = this._toolFilter.toLowerCase(); const current = this._form.tool_pattern; const allTools = [ { name: '*', description: 'Any tool', source: 'glob', server: null }, { name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null }, ...this._tools.built_in, ...this._tools.mcp, ]; const filtered = allTools.filter(t => !q || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || (t.server && t.server.toLowerCase().includes(q)) ); const groups = {}; for (const t of filtered) { const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob'; if (!groups[key]) groups[key] = []; groups[key].push(t); } return html`
{ this._toolFilter = e.target.value; }} />
${Object.entries(groups).map(([group, tools]) => html`
${group}
${tools.map(t => html` `)} `)} ${filtered.length === 0 ? html`
No results
` : nothing}
`; } // ── Override / LowPrio rule form ────────────────────────────────────────────── _renderForm() { const f = this._form; const isOverride = this._formMode === 'override'; return html`
${this._editingId === 'new' ? (isOverride ? 'New override rule' : 'New low priority rule') : 'Edit rule'}
this._patch('tool_pattern', e.target.value)} />
Use * as a trailing wildcard, e.g. mcp__whatsapp__*
${this._renderToolPicker()}
this._patch('path_pattern', e.target.value)} />
Filter by file path. Use * as a wildcard.
this._patch('priority', e.target.value)} />
${isOverride ? html`Must be < 0 (e.g. −10)` : html`Must be 1 – ${DEFAULT_PRIORITY - 1}`}
this._patch('agent_id', e.target.value)} />
this._patch('note', e.target.value)} />
`; } // ── Rule card ───────────────────────────────────────────────────────────────── _renderCard(rule) { const s = ACTION_STYLE[rule.action] ?? ACTION_STYLE.require; const has = (v) => v != null && v !== ''; return html`
${s.label} ${rule.tool_pattern} ${rule.priority}
${has(rule.path_pattern) ? html`
${rule.path_pattern}
` : ''}
${has(rule.source) ? html`${rule.source}` : ''} ${has(rule.agent_id) ? html`${rule.agent_id}` : ''} ${has(rule.note) ? html`${rule.note}` : ''}
`; } // ── 4-state chip group ──────────────────────────────────────────────────────── _renderChipGroup(currentAction, onChange) { const chips = [ { action: null, label: '—' }, { action: 'allow', label: 'Allow' }, { action: 'require', label: 'Req' }, { action: 'deny', label: 'Deny' }, ]; return html`
${chips.map(({ action, label }) => { const isActive = currentAction === action; return html` `; })}
`; } // ── Tool row ───────────────────────────────────────────────────────────────── _renderToolRow(tool) { const action = this._getToolAction(tool.name); const saving = this._toolSaving.has(tool.name); return html`
${tool.name} ${saving ? html`` : this._renderChipGroup(action, (a) => this._setToolAction(tool.name, a)) }
`; } // ── Category section ───────────────────────────────────────────────────────── _renderCategorySection(key, tools, description) { const open = this._openSections.has(key); const groupId = this._selectedGroup.id; const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length; return html`
this._toggleSection(key)}> ${key} ${description ? html`${description}` : nothing} ${configured > 0 ? `${configured}/` : ''}${tools.length}
${tools.map(t => this._renderToolRow(t))}
`; } // ── Tool matrix ─────────────────────────────────────────────────────────────── _renderToolMatrix() { const groups = this._groupedTools(); return html`
Per-tool priority = 0 · exact tool name · no path/source filters
${groups.length === 0 ? html`
Loading tools…
` : groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
`; } // ── File System panel ───────────────────────────────────────────────────────── _renderFsAccessSelect(value, onChange, allowUnset) { return html` `; } _renderFsRow(rule) { const saving = this._fsSaving.has(rule.id); const value = this._fsAccessValue(rule); return html`
${this._fsDisplayPath(rule)} ${saving ? html`` : html` ${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)} `}
`; } _renderFsAddRow() { const saving = this._fsSaving.has('new'); return html`
{ this._fsNewPath = e.target.value; }} @keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }} />
`; } _renderFsPanel() { const groupId = this._selectedGroup.id; const rules = this._fsPathRules(groupId); const isOpen = this._fsOpen; const defRule = this._fsDefaultRule(groupId); const defValue = defRule ? this._fsAccessValue(defRule) : null; return html`
{ this._fsOpen = !this._fsOpen; }}> File System path-scoped read / write access ${rules.length > 0 ? html`${rules.length}` : nothing}
${isOpen ? html`
${rules.length === 0 ? html`
No path rules yet — add one below.
` : rules.map(r => this._renderFsRow(r))} ${this._renderFsAddRow()}
Default unmatched paths ${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)}
` : nothing}
`; } // ── Side panel (Override / LowPrio) ────────────────────────────────────────── _renderSidePanel(panelKey, title, icon, subtitle, rules, isOpen, onToggle, onAdd) { const formActive = this._editingId !== null && this._formMode === panelKey; return html`
${title} ${subtitle} ${rules.length > 0 ? html`${rules.length}` : nothing}
${isOpen ? html`
${formActive ? this._renderForm() : nothing} ${rules.length === 0 && !formActive ? html`
No rules yet.
` : rules.map(r => this._renderCard(r))}
` : nothing}
`; } // ── Default action bar ──────────────────────────────────────────────────────── _renderDefaultActionBar() { const action = this._getDefaultAction(); return html`
Default action if no rule matches
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))} ${action === null ? html`system default: allow` : nothing}
`; } // ── Rules view ──────────────────────────────────────────────────────────────── render() { if (!this._selectedGroup) return nothing; const group = this._selectedGroup; const isDefault = group.id === 'default'; const { overrides, lowPrio } = this._buckets(group.id); const totalRules = this._rulesForGroup(group.id).length; return html`

${isDefault ? html`Default` : nothing} ${group.name}

${totalRules} rule${totalRules === 1 ? '' : 's'}
${this._error ? html`
${this._error}
` : nothing}
${this._renderSidePanel( 'override', 'Overrides', 'bi-exclamation-triangle-fill', 'priority < 0 · evaluated first', overrides, this._overrideOpen, () => { this._overrideOpen = !this._overrideOpen; }, () => this._startNew('override') )} ${this._renderFsPanel()} ${this._renderToolMatrix()} ${this._renderSidePanel( 'lowprio', 'Low Priority', 'bi-arrow-down-circle-fill', 'priority 1–999998 · evaluated after per-tool', lowPrio, this._lowPrioOpen, () => { this._lowPrioOpen = !this._lowPrioOpen; }, () => this._startNew('lowprio') )} ${this._renderDefaultActionBar()}
`; } }