1077 lines
42 KiB
JavaScript
1077 lines
42 KiB
JavaScript
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>< 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 1–999998 · evaluated after per-tool',
|
||
lowPrio,
|
||
this._lowPrioOpen,
|
||
() => { this._lowPrioOpen = !this._lowPrioOpen; },
|
||
() => this._startNew('lowprio')
|
||
)}
|
||
|
||
${this._renderDefaultActionBar()}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|