Files
2026-07-10 15:02:09 +01:00

1077 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>
_overrideOpen: { state: true },
_lowPrioOpen: { state: true },
_toolSaving: { state: true }, // Set<string>
_fsOpen: { state: true },
_fsNewPath: { state: true },
_fsNewAccess: { state: true },
_fsSaving: { state: true }, // Set<number|'new'>
};
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`
<div class="apr-tool-picker">
<input
class="form-control form-control-sm mb-2"
placeholder="Search tools…"
.value=${this._toolFilter}
@input=${(e) => { this._toolFilter = e.target.value; }}
/>
<div class="apr-tool-list">
${Object.entries(groups).map(([group, tools]) => html`
<div class="apr-tool-group-label">${group}</div>
${tools.map(t => html`
<button
class="apr-tool-item ${current === t.name ? 'selected' : ''}"
@click=${() => this._selectTool(t.name)}
title=${t.description}
>
<code class="apr-tool-name">${t.name}</code>
<span class="apr-tool-desc">${t.description}</span>
</button>
`)}
`)}
${filtered.length === 0 ? html`<div class="text-muted p-2">No results</div>` : nothing}
</div>
</div>
`;
}
// ── Override / LowPrio rule form ──────────────────────────────────────────────
_renderForm() {
const f = this._form;
const isOverride = this._formMode === 'override';
return html`
<div class="apr-form">
<div class="apr-form-header">
<i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i>
<span>${this._editingId === 'new'
? (isOverride ? 'New override rule' : 'New low priority rule')
: 'Edit rule'}</span>
<button class="apr-form-close" @click=${() => this._cancelEdit()}>
<i class="bi bi-x"></i>
</button>
</div>
<div class="apr-form-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Tool pattern <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. mcp__whatsapp__* or execute_cmd"
.value=${f.tool_pattern}
@input=${(e) => this._patch('tool_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Use <code>*</code> as a trailing wildcard, e.g. <code>mcp__whatsapp__*</code></div>
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Select tool</label>
${this._renderToolPicker()}
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path pattern <span class="text-muted fw-normal">(optional)</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. data/* or data/notes/*"
.value=${f.path_pattern}
@input=${(e) => this._patch('path_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Filter by file path. Use <code>*</code> as a wildcard.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Action</label>
<select
class="form-select form-select-sm"
.value=${f.action}
@change=${(e) => this._patch('action', e.target.value)}
>
${ACTIONS.map(a => html`<option value=${a} ?selected=${f.action === a}>${a}</option>`)}
</select>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
<input
type="number"
class="form-control form-control-sm"
.value=${String(f.priority)}
@input=${(e) => this._patch('priority', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">
${isOverride
? html`Must be <strong>&lt; 0</strong> (e.g. 10)`
: html`Must be <strong>1 ${DEFAULT_PRIORITY - 1}</strong>`}
</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Source <span class="text-muted fw-normal">(optional)</span></label>
<select
class="form-select form-select-sm"
@change=${(e) => this._patch('source', e.target.value)}
>
<option value="" ?selected=${!f.source}>Any</option>
${['web', 'telegram', 'cron'].map(s => html`
<option value=${s} ?selected=${f.source === s}>${s}</option>
`)}
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent ID <span class="text-muted fw-normal">(optional)</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="main (empty = any)"
.value=${f.agent_id}
@input=${(e) => this._patch('agent_id', e.target.value)}
/>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Note <span class="text-muted fw-normal">(optional)</span></label>
<input
class="form-control form-control-sm"
placeholder="Short description…"
.value=${f.note}
@input=${(e) => this._patch('note', e.target.value)}
/>
</div>
</div>
<div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>Cancel</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()} ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>Save`}
</button>
</div>
</div>
</div>
`;
}
// ── Rule card ─────────────────────────────────────────────────────────────────
_renderCard(rule) {
const s = ACTION_STYLE[rule.action] ?? ACTION_STYLE.require;
const has = (v) => v != null && v !== '';
return html`
<div class="apr-card" style="--apr-action-bg: ${s.bg}; --apr-action-color: ${s.color}">
<div class="apr-card-row1">
<span class="apr-action-badge">
<i class="bi ${s.icon}"></i>
${s.label}
</span>
<code class="apr-pattern">${rule.tool_pattern}</code>
<span class="apr-priority-badge" title="Priority">
<i class="bi bi-list-ol"></i>
${rule.priority}
</span>
<div class="apr-card-actions">
<button class="apr-btn-icon apr-btn-edit" title="Edit" @click=${() => this._startEdit(rule)}>
<i class="bi bi-pencil"></i>
</button>
<button class="apr-btn-icon apr-btn-delete" title="Delete" @click=${() => this._delete(rule)}>
<i class="bi bi-trash"></i>
</button>
</div>
</div>
${has(rule.path_pattern) ? html`
<div class="apr-card-row2">
<span class="apr-tag"><i class="bi bi-folder2"></i><code>${rule.path_pattern}</code></span>
</div>
` : ''}
<div class="apr-card-row3">
${has(rule.source) ? html`<span class="apr-tag"><i class="bi bi-box-arrow-in-right"></i>${rule.source}</span>` : ''}
${has(rule.agent_id) ? html`<span class="apr-tag"><i class="bi bi-robot"></i>${rule.agent_id}</span>` : ''}
${has(rule.note) ? html`<span class="apr-tag apr-tag-note"><i class="bi bi-chat-text"></i>${rule.note}</span>` : ''}
</div>
</div>
`;
}
// ── 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`
<div class="apr-chip-group">
${chips.map(({ action, label }) => {
const isActive = currentAction === action;
return html`
<button
class="apr-chip ${isActive ? 'active' : ''}"
data-action=${action ?? 'unset'}
@click=${() => onChange(isActive && action !== null ? null : action)}
>${label}</button>
`;
})}
</div>
`;
}
// ── Tool row ─────────────────────────────────────────────────────────────────
_renderToolRow(tool) {
const action = this._getToolAction(tool.name);
const saving = this._toolSaving.has(tool.name);
return html`
<div class="apr-tool-row">
<code class="apr-matrix-tool-name" title=${tool.description}>${tool.name}</code>
${saving
? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0;color:var(--bs-secondary-color)"></span>`
: this._renderChipGroup(action, (a) => this._setToolAction(tool.name, a))
}
</div>
`;
}
// ── 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`
<div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}">
<div class="apr-cat-header" @click=${() => this._toggleSection(key)}>
<i class="bi bi-chevron-${open ? 'down' : 'right'} apr-cat-chevron"></i>
<span class="apr-cat-name">${key}</span>
${description ? html`<span class="apr-cat-desc">${description}</span>` : nothing}
<span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}">
${configured > 0 ? `${configured}/` : ''}${tools.length}
</span>
</div>
<div class="apr-cat-body">
${tools.map(t => this._renderToolRow(t))}
</div>
</div>
`;
}
// ── Tool matrix ───────────────────────────────────────────────────────────────
_renderToolMatrix() {
const groups = this._groupedTools();
return html`
<div class="apr-matrix">
<div class="apr-matrix-header">
<span class="apr-matrix-title">Per-tool</span>
<span class="apr-matrix-subtitle">priority = 0 · exact tool name · no path/source filters</span>
</div>
<div class="apr-matrix-body">
${groups.length === 0
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">Loading tools…</div>`
: groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
</div>
</div>
`;
}
// ── File System panel ─────────────────────────────────────────────────────────
_renderFsAccessSelect(value, onChange, allowUnset) {
return html`
<select
class="form-select form-select-sm apr-fs-select"
@change=${(e) => onChange(e.target.value || null)}
>
${allowUnset
? html`<option value="" ?selected=${!value}>Require (system default)</option>`
: nothing}
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${value === k}>${v.label}</option>
`)}
</select>
`;
}
_renderFsRow(rule) {
const saving = this._fsSaving.has(rule.id);
const value = this._fsAccessValue(rule);
return html`
<div class="apr-fs-row">
<i class="bi bi-folder2 apr-fs-row-icon"></i>
<code class="apr-fs-path" title=${rule.path_pattern ?? ''}>${this._fsDisplayPath(rule)}</code>
${saving
? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0"></span>`
: html`
${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)}
<button class="apr-btn-icon apr-btn-delete" title="Remove" @click=${() => this._deleteFsRule(rule)}>
<i class="bi bi-trash"></i>
</button>
`}
</div>
`;
}
_renderFsAddRow() {
const saving = this._fsSaving.has('new');
return html`
<div class="apr-fs-row apr-fs-add">
<i class="bi bi-plus-circle apr-fs-row-icon"></i>
<input
class="form-control form-control-sm font-monospace apr-fs-path-input"
placeholder="Add directory path, e.g. docs"
.value=${this._fsNewPath}
@input=${(e) => { this._fsNewPath = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }}
/>
<select
class="form-select form-select-sm apr-fs-select"
@change=${(e) => { this._fsNewAccess = e.target.value; }}
>
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${v.label}</option>
`)}
</select>
<button class="btn btn-sm btn-primary apr-fs-add-btn" @click=${() => this._addFsRule()} ?disabled=${saving}>
${saving
? html`<span class="spinner-border spinner-border-sm"></span>`
: html`<i class="bi bi-plus-lg"></i>`}
</button>
</div>
`;
}
_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`
<div class="apr-side-panel apr-fs-panel ${isOpen ? 'apr-side-panel--open' : ''}">
<div class="apr-side-panel-header" @click=${() => { this._fsOpen = !this._fsOpen; }}>
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
<i class="bi bi-hdd-stack apr-panel-icon"></i>
<span class="apr-panel-title">File System</span>
<span class="apr-panel-subtitle">path-scoped read / write access</span>
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${rules.length === 0
? html`<div class="apr-panel-empty">No path rules yet — add one below.</div>`
: rules.map(r => this._renderFsRow(r))}
${this._renderFsAddRow()}
<div class="apr-fs-row apr-fs-default">
<i class="bi bi-skip-end-fill apr-fs-row-icon"></i>
<span class="apr-fs-path apr-fs-default-label">Default <span class="apr-default-hint">unmatched paths</span></span>
${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)}
</div>
</div>
` : nothing}
</div>
`;
}
// ── Side panel (Override / LowPrio) ──────────────────────────────────────────
_renderSidePanel(panelKey, title, icon, subtitle, rules, isOpen, onToggle, onAdd) {
const formActive = this._editingId !== null && this._formMode === panelKey;
return html`
<div class="apr-side-panel ${isOpen ? 'apr-side-panel--open' : ''}">
<div class="apr-side-panel-header" @click=${onToggle}>
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
<i class="bi ${icon} apr-panel-icon"></i>
<span class="apr-panel-title">${title}</span>
<span class="apr-panel-subtitle">${subtitle}</span>
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
<button
class="btn btn-sm btn-outline-secondary apr-panel-add-btn"
@click=${(e) => { e.stopPropagation(); onAdd(); }}
><i class="bi bi-plus-lg me-1"></i>Add</button>
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${formActive ? this._renderForm() : nothing}
${rules.length === 0 && !formActive
? html`<div class="apr-panel-empty">No rules yet.</div>`
: rules.map(r => this._renderCard(r))}
</div>
` : nothing}
</div>
`;
}
// ── Default action bar ────────────────────────────────────────────────────────
_renderDefaultActionBar() {
const action = this._getDefaultAction();
return html`
<div class="apr-default-bar">
<div class="apr-default-label">
<i class="bi bi-skip-end-fill me-1"></i>
<strong>Default action</strong>
<span class="apr-default-hint">if no rule matches</span>
</div>
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))}
${action === null
? html`<span class="apr-default-unset">system default: allow</span>`
: nothing}
</div>
`;
}
// ── 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`
<div class="apr-page">
<div class="apr-header">
<button
class="btn btn-sm btn-outline-secondary"
style="flex-shrink:0"
@click=${() => this._goBack()}
>
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="apr-title">
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">Default</span>` : nothing}
${group.name}
</h2>
<div class="apr-header-right">
<span class="apr-header-count">${totalRules} rule${totalRules === 1 ? '' : 's'}</span>
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
<div class="apr-rules-body">
${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 1999998 · evaluated after per-tool',
lowPrio,
this._lowPrioOpen,
() => { this._lowPrioOpen = !this._lowPrioOpen; },
() => this._startNew('lowprio')
)}
${this._renderDefaultActionBar()}
</div>
</div>
`;
}
}