/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `AuditLogPanel` — local audit log viewer rendered inside the * Extensions dock. * * Reads from the extension host's append-only audit log. Surfaces: * - install / uninstall / update / enable / disable * - activate / deactivate * - capability grant / revoke * - mutation summary / network fetch (when those land) * - health events (unhealthy / killed) * * The log is local-only. The "Export" button writes a JSON snapshot * the user can keep / share. Clearing is one-click; there's no * cross-device sync to worry about. * * Spec: docs/architecture/ai-customization/02-security.md §12. */ import { useEffect, useState } from 'react'; import { Download, Trash2, FileText, Filter, X } from 'lucide-react'; import type { AuditEvent, AuditEventKind } from '@ifc-lite/extensions'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { toast } from '@/components/ui/toast'; import { HelpHint } from './HelpHint'; const KIND_LABELS: Record = { install: 'Install', uninstall: 'Uninstall', update: 'Update', enable: 'Enable', disable: 'Disable', capability_grant: 'Granted', capability_revoke: 'Revoked', activate: 'Activate', deactivate: 'Deactivate', mutation_summary: 'Mutations', network_fetch: 'Fetch', unhealthy: 'Unhealthy', killed: 'Killed', }; const KIND_TONES: Record = { install: 'text-emerald-600 dark:text-emerald-400', uninstall: 'text-rose-600 dark:text-rose-400', update: 'text-sky-600 dark:text-sky-400', enable: 'text-emerald-600 dark:text-emerald-400', disable: 'text-muted-foreground', capability_grant: 'text-amber-600 dark:text-amber-400', capability_revoke: 'text-amber-600 dark:text-amber-400', activate: 'text-muted-foreground', deactivate: 'text-muted-foreground', mutation_summary: 'text-sky-600 dark:text-sky-400', network_fetch: 'text-purple-600 dark:text-purple-400', unhealthy: 'text-amber-600 dark:text-amber-400', killed: 'text-rose-600 dark:text-rose-400', }; interface AuditLogPanelProps { /** Show only events from this extension id. Omit for all. */ extensionId?: string; /** When in a panel, the close button. */ onClose?: () => void; } export function AuditLogPanel({ extensionId, onClose }: AuditLogPanelProps) { const host = useExtensionHost(); const [events, setEvents] = useState([]); const [filter, setFilter] = useState('all'); // Per-extension filter applied on top of the props-level filter. // The prop scopes the panel; this state is the user's runtime // narrow-down ("show only events for this extension"). const [extensionFilter, setExtensionFilter] = useState(extensionId); useEffect(() => { const scope = extensionFilter ?? extensionId; setEvents(host.audit.list(scope ? { extensionId: scope } : {})); const off = host.onChange(() => { setEvents(host.audit.list(scope ? { extensionId: scope } : {})); }); return off; }, [host, extensionId, extensionFilter]); const filtered = filter === 'all' ? events : events.filter((e) => e.kind === filter); // Build the list of distinct extension ids present in the (unfiltered) // events for the per-extension chip row. const distinctExtensionIds = Array.from( new Set(host.audit.list().map((e) => e.extensionId)), ).sort(); const handleExport = () => { const json = host.audit.exportJson(); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ifclite-audit-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); toast.success('Audit log exported.'); }; const handleClear = () => { if (!confirm('Clear the audit log? This cannot be undone.')) return; host.audit.clear(); // Wipe the IDB mirror too — otherwise reload resurrects what the // user just asked to forget. void host.clearPersistedAuditLog().catch((err) => { console.warn('[AuditLogPanel] clear persisted audit failed:', err); }); setEvents([]); toast.success('Audit log cleared.'); }; return (

Audit Log

{filtered.length} of {events.length} events

Append-only ledger of every extension lifecycle event: install, update, enable, disable, activate, capability grant/revoke, runtime failures.

Persists in IndexedDB across reloads. Filter by event kind via the chips below; when multiple extensions are installed, a second chip row scopes by extension id.

Export downloads a JSON snapshot.

{onClose && ( )}
setFilter('all')} /> {(Object.keys(KIND_LABELS) as AuditEventKind[]).map((k) => ( setFilter(k)} /> ))}
{/* Extension scope row — appears only when the panel was opened un-scoped AND there's more than one extension in the log. Lets the user narrow "show only events for this extension". */} {!extensionId && distinctExtensionIds.length > 1 && (
Extension: setExtensionFilter(undefined)} /> {distinctExtensionIds.map((id) => ( setExtensionFilter(id)} /> ))}
)} {filtered.length === 0 ? (
No events yet. Audit entries appear here when extensions are installed, updated, enabled, disabled, or uninstalled.
) : (
    {filtered.slice().reverse().map((event) => (
  • {KIND_LABELS[event.kind]}
    {event.extensionId}
    {new Date(event.ts).toLocaleString()} {event.version ? ` · v${event.version}` : ''} {extraDetail(event)}
  • ))}
)}
); } function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { return ( ); } function extraDetail(event: AuditEvent): string { switch (event.kind) { case 'install': case 'update': return event.grantedCapabilities ? ` · ${event.grantedCapabilities.length} capability ${event.grantedCapabilities.length === 1 ? 'grant' : 'grants'}` : ''; case 'mutation_summary': return ` · ${event.entityCount} entities`; case 'network_fetch': return ` · ${event.host} (${event.bytes} bytes)`; case 'unhealthy': case 'killed': return ` · ${event.reason}`; default: return ''; } }