enlil 0.1.1

Vendor-neutral open-source control and audit plane for AI agent actions. Sits inline between your agents and any model or tool: enforce what each agent may do, and prove what it did.
Documentation
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Enlil — Agent Control Plane</title>
<style>
:root {
  --bg: #0f1117;
  --surface: #1a1d27;
  --border: #2a2d3a;
  --text: #e4e4e7;
  --muted: #71717a;
  --accent: #6366f1;
  --green: #22c55e;
  --red: #ef4444;
  --orange: #f59e0b;
  --blue: #3b82f6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.5;
  min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
header {
  display: flex; align-items: center; justify-content: space-between;
  padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem;
}
header h1 { font-size: 1.5rem; font-weight: 600; }
header h1 span { color: var(--accent); }
.status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: var(--muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }

/* Stats grid */
.stats-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 1rem; margin-bottom: 2rem;
}
.stat-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; padding: 1rem;
}
.stat-card .label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.stat-card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
.stat-card .value.green { color: var(--green); }
.stat-card .value.red { color: var(--red); }
.stat-card .value.orange { color: var(--orange); }
.stat-card .value.blue { color: var(--blue); }

/* Tabs */
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
.tab {
  padding: 0.75rem 1.25rem; cursor: pointer; font-size: 0.875rem;
  color: var(--muted); border-bottom: 2px solid transparent; transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }

/* Panel */
.panel { display: none; }
.panel.active { display: block; }

/* Traces table */
.traces-table { width: 100%; border-collapse: collapse; }
.traces-table th, .traces-table td {
  padding: 0.625rem 0.75rem; text-align: left; font-size: 0.8125rem;
  border-bottom: 1px solid var(--border);
}
.traces-table th { color: var(--muted); font-weight: 500; text-transform: uppercase; font-size: 0.6875rem; letter-spacing: 0.05em; }
.traces-table tr:hover { background: rgba(99,102,241,0.05); cursor: pointer; }
.badge {
  display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px;
  font-size: 0.6875rem; font-weight: 500;
}
.badge-ok { background: rgba(34,197,94,0.15); color: var(--green); }
.badge-blocked { background: rgba(239,68,68,0.15); color: var(--red); }
.badge-cached { background: rgba(59,130,246,0.15); color: var(--blue); }
.badge-alert { background: rgba(245,158,11,0.15); color: var(--orange); }
.badge-proto { background: rgba(99,102,241,0.15); color: var(--accent); }
.traces-table td.path { font-family: monospace; font-size: 0.78rem; color: var(--muted); }
.traces-table tr:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.empty { color: var(--muted); padding: 1.5rem 0.75rem; text-align: center; font-size: 0.8125rem; }
.empty code { background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 4px; }

/* Rules list */
.rule-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem;
}
.rule-card .rule-name { font-weight: 600; font-size: 0.875rem; }
.rule-card .rule-action { font-size: 0.75rem; margin-top: 0.25rem; }
.rule-card .rule-pattern { font-size: 0.75rem; color: var(--muted); margin-top: 0.5rem; font-family: monospace; }

/* Trace detail modal */
.modal-overlay {
  display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7);
  z-index: 100; align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
  max-width: 700px; width: 90vw; max-height: 80vh; overflow-y: auto; padding: 1.5rem;
}
.modal h2 { font-size: 1rem; margin-bottom: 1rem; }
.modal pre {
  background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
  padding: 1rem; font-size: 0.75rem; overflow-x: auto; white-space: pre-wrap;
}
.modal .close-btn {
  position: absolute; top: 1rem; right: 1rem; background: none; border: none;
  color: var(--muted); cursor: pointer; font-size: 1.25rem;
}

/* Events feed */
.event-item {
  padding: 0.5rem 0; border-bottom: 1px solid var(--border); font-size: 0.8125rem;
}
.event-item .event-time { color: var(--muted); font-size: 0.75rem; font-family: monospace; }

/* Responsive */
@media (max-width: 640px) {
  .stats-grid { grid-template-columns: repeat(2, 1fr); }
  .traces-table { font-size: 0.75rem; }
}
</style>
</head>
<body>
<div class="container">
  <header>
    <h1><span>Enlil</span> Control Plane</h1>
    <div class="status"><div class="status-dot"></div>Live</div>
  </header>

  <div class="stats-grid" id="stats-grid"></div>

  <div class="tabs">
    <div class="tab active" data-panel="traces">Traces</div>
    <div class="tab" data-panel="rules">Rules</div>
    <div class="tab" data-panel="events">Events</div>
  </div>

  <div class="panel active" id="panel-traces">
    <table class="traces-table">
      <thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Protocol</th><th>Status</th><th>Latency</th><th>Decision</th></tr></thead>
      <tbody id="traces-body"></tbody>
    </table>
  </div>

  <div class="panel" id="panel-rules">
    <div id="rules-list"></div>
  </div>

  <div class="panel" id="panel-events">
    <div id="events-list"></div>
  </div>
</div>

<div class="modal-overlay" id="modal-overlay">
  <div class="modal">
    <h2 id="modal-title">Trace Detail</h2>
    <pre id="modal-content"></pre>
  </div>
</div>

<script>
const API = window.location.origin;

// Tabs
document.querySelectorAll('.tab').forEach(tab => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
    document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
    tab.classList.add('active');
    document.getElementById('panel-' + tab.dataset.panel).classList.add('active');
  });
});

// Modal
const overlay = document.getElementById('modal-overlay');
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.remove('active'); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') overlay.classList.remove('active'); });

function showTrace(id) {
  fetch(API + '/api/traces/' + id)
    .then(r => r.json())
    .then(data => {
      document.getElementById('modal-title').textContent = 'Trace ' + id.slice(0, 8);
      document.getElementById('modal-content').textContent = JSON.stringify(data, null, 2);
      overlay.classList.add('active');
    });
}

// Stats
function loadStats() {
  fetch(API + '/api/stats')
    .then(r => r.json())
    .then(s => {
      const grid = document.getElementById('stats-grid');
      grid.innerHTML = [
        card('Requests', s.total_requests, ''),
        card('Cache Hits', s.cache_hits, 'green'),
        card('Policy Blocks', s.policy_blocks, 'red'),
        card('Injection Blocks', s.injection_blocks, 'red'),
        card('PII Redactions', s.pii_redactions, 'orange'),
        card('Loop Breaks', s.loop_breaks, 'orange'),
        card('Tokens Used', fmt(s.total_tokens_used), 'blue'),
        card('Avg Latency', fmtDuration(s.avg_latency_us), ''),
      ].join('');
    })
    .catch(() => {});
}

function card(label, value, color) {
  return '<div class="stat-card"><div class="label">' + label + '</div><div class="value ' + color + '">' + value + '</div></div>';
}

function fmt(n) { return n > 1000000 ? (n/1000000).toFixed(1) + 'M' : n > 1000 ? (n/1000).toFixed(1) + 'K' : n; }

// The API reports microseconds. Show a unit a human can read at a glance.
function fmtDuration(us) {
  if (us === undefined || us === null) return '-';
  if (us < 1000) return us + 'µs';
  if (us < 1000000) return (us / 1000).toFixed(1) + 'ms';
  return (us / 1000000).toFixed(2) + 's';
}

// Trace timestamps are unix *seconds*, not milliseconds.
function fmtTime(ts) {
  if (!ts) return '-';
  return new Date(ts * 1000).toLocaleTimeString();
}

// Traces
function loadTraces() {
  fetch(API + '/api/traces?limit=50')
    .then(r => r.json())
    .then(data => {
      const tbody = document.getElementById('traces-body');
      const traces = data.traces || [];
      if (!traces.length) {
        tbody.innerHTML = '<tr><td colspan="7" class="empty">No agent traffic yet. '
          + 'Point a client at <code>' + API + '</code> and requests will appear here.</td></tr>';
        return;
      }
      tbody.innerHTML = traces.map(t =>
        '<tr onclick="showTrace(\'' + esc(t.trace_id) + '\')" tabindex="0">' +
          '<td>' + fmtTime(t.timestamp) + '</td>' +
          '<td>' + esc(t.method || '-') + '</td>' +
          '<td class="path">' + esc(t.path || '-') + '</td>' +
          '<td>' + protocolBadge(t.protocol) + '</td>' +
          '<td>' + statusBadge(t.status) + '</td>' +
          '<td>' + fmtDuration(t.latency_us) + '</td>' +
          '<td>' + decisionBadge(t) + '</td>' +
        '</tr>'
      ).join('');
    })
    .catch(() => {});
}

function statusBadge(s) {
  if (!s) return '-';
  const cls = s >= 500 ? 'badge-blocked' : s === 403 || s === 429 ? 'badge-blocked'
            : s >= 400 ? 'badge-alert' : 'badge-ok';
  return '<span class="badge ' + cls + '">' + s + '</span>';
}

// Protocol is worth surfacing: it shows the same engine governing OpenAI, MCP
// and unrecognised payloads side by side.
function protocolBadge(p) {
  if (!p) return '-';
  return '<span class="badge badge-proto">' + esc(p) + '</span>';
}

function decisionBadge(t) {
  if (t.blocked) return '<span class="badge badge-blocked">blocked</span>';
  if (t.cache === 'hit') return '<span class="badge badge-cached">cached</span>';
  return '<span class="badge badge-ok">pass</span>';
}

// Rules
function loadRules() {
  fetch(API + '/api/rules')
    .then(r => r.json())
    .then(data => {
      const list = document.getElementById('rules-list');
      const rules = data.rules || [];
      if (!rules.length) { list.innerHTML = '<p class="empty">No rules loaded.</p>'; return; }
      list.innerHTML = rules.map(r => {
        // `action` is a Rust enum, so it arrives capitalised ("Block"/"Redact"/"Alert"/"Log").
        const action = String(r.action || '').toLowerCase();
        const cls = action === 'block' ? 'badge-blocked' : action === 'alert' ? 'badge-alert'
                  : action === 'redact' ? 'badge-cached' : 'badge-ok';
        return '<div class="rule-card">' +
          '<div class="rule-name">' + esc(r.name || r.id || 'rule') +
            (r.enabled === false ? ' <span class="badge badge-alert">disabled</span>' : '') + '</div>' +
          '<div class="rule-action"><span class="badge ' + cls + '">' + esc(action || 'log') + '</span></div>' +
          '<div class="rule-pattern">' + esc(describeCondition(r.condition)) + '</div>' +
        '</div>';
      }).join('');
    })
    .catch(() => {});
}

// A rule's condition is an externally-tagged enum, e.g. {"BodyRegex": "..."}.
function describeCondition(cond) {
  if (!cond) return '';
  if (typeof cond === 'string') return cond;
  const kind = Object.keys(cond)[0];
  if (!kind) return '';
  const val = cond[kind];
  return kind + ': ' + (typeof val === 'object' ? JSON.stringify(val) : String(val));
}

// Events
function loadEvents() {
  fetch(API + '/api/events/recent')
    .then(r => r.json())
    .then(data => {
      const list = document.getElementById('events-list');
      const events = (data.events || []).slice().reverse().slice(0, 50);
      if (!events.length) { list.innerHTML = '<p class="empty">No events yet.</p>'; return; }
      list.innerHTML = events.map(e =>
        '<div class="event-item">' +
          '<span class="event-time">' + fmtTime(e.timestamp) + '</span> ' +
          '<span class="badge ' + (String(e.event_type||'').match(/block|inject|exfil|loop/i) ? 'badge-blocked' : 'badge-ok') + '">' +
            esc(e.event_type || 'event') + '</span> ' +
          esc(e.detail || '') +
        '</div>'
      ).join('');
    })
    .catch(() => {});
}

function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }

// Initial load + auto-refresh
function refresh() { loadStats(); loadTraces(); loadEvents(); }
refresh();
loadRules();
setInterval(refresh, 5000);
</script>
</body>
</html>