Phase 9: Clearance Protocol — intercept, approve/deny, HUD, voice commands

- reactor.py v9.0.0: clearance endpoints, watchdog, create_job intercept
- arc.php: 7 clearance actions (pending/history/approve/deny/rules/rule_update/create)
- chat.php: Tier 0.9j voice commands — approve/deny/status clearance
- index.html: clearance banner, CLEARANCE tab with pending requests + rules + history
- admin/index.php: CLEARANCE nav + tab with full CRUD for rules and approve/deny UI
This commit is contained in:
2026-06-11 12:19:14 +00:00
parent aaf07edacb
commit 93d7594c4f
4 changed files with 615 additions and 0 deletions
+282
View File
@@ -763,6 +763,76 @@ if ($action) {
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
// ── CLEARANCE PROTOCOL ───────────────────────────────────────────────
case 'clearance_pending':
$ch = curl_init('http://127.0.0.1:7474/clearance/pending');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_history':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$ch = curl_init('http://127.0.0.1:7474/clearance/history?limit=' . $limit);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_approve':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$decidedBy = $body['decided_by'] ?? 'admin';
$ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/approve');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_deny':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$decidedBy = $body['decided_by'] ?? 'admin';
$note = $body['note'] ?? '';
$ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/deny');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy,'note'=>$note]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_rules':
$ch = curl_init('http://127.0.0.1:7474/clearance/rules');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_rule_update':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$ch = curl_init('http://127.0.0.1:7474/clearance/rules/' . $id);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT',
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_rule_create':
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$ch = curl_init('http://127.0.0.1:7474/clearance/rules');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
// ── VISION PROTOCOL ──────────────────────────────────────────────────
case 'vision_list':
$limit = min((int)($_GET['limit'] ?? 30), 100);
@@ -1100,6 +1170,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="nav-item" data-tab="guardian" onclick="nav(this)" id="nav-guardian">◈ GUARDIAN MODE</div>
<div class="nav-item" data-tab="missions" onclick="nav(this)">◈ MISSION OPS</div>
<div class="nav-item" data-tab="directives" onclick="nav(this)">◈ DIRECTIVES</div>
<div class="nav-item" data-tab="clearance" onclick="nav(this)" id="nav-clearance">🔒 CLEARANCE</div>
<div class="nav-section">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
@@ -1657,6 +1728,56 @@ select.filter-sel:focus{border-color:var(--cyan)}
</div>
</div>
<!-- CLEARANCE PROTOCOL -->
<div class="tab" id="tab-clearance">
<div class="page-title">🔒 CLEARANCE PROTOCOL</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm" onclick="loadClearance()">↻ REFRESH</button>
<div id="clearance-badge" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<!-- Pending requests -->
<div>
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--red);margin-bottom:10px">PENDING AUTHORIZATION</div>
<div id="clearance-pending-list"><div class="loading">LOADING...</div></div>
</div>
<!-- Rules configuration -->
<div>
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--cyan);margin-bottom:10px">CLEARANCE RULES</div>
<div id="clearance-rules-list"><div class="loading">LOADING...</div></div>
<div style="margin-top:10px">
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:1px;color:var(--dim);margin-bottom:6px">ADD CUSTOM RULE</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px">
<div><div class="lbl">JOB TYPE</div><input id="clr-new-type" class="inp" placeholder="job_type"></div>
<div><div class="lbl">RISK LEVEL</div>
<select id="clr-new-risk" class="inp">
<option value="medium">MEDIUM</option>
<option value="high" selected>HIGH</option>
<option value="critical">CRITICAL</option>
</select>
</div>
<div><div class="lbl">REQUIRE APPROVAL</div>
<select id="clr-new-req" class="inp">
<option value="1" selected>YES</option>
<option value="0">NO (LOG ONLY)</option>
</select>
</div>
<div><div class="lbl">AUTO-APPROVE AFTER (MIN)</div><input id="clr-new-auto" class="inp" type="number" placeholder="blank=never"></div>
</div>
<input id="clr-new-desc" class="inp" placeholder="Description" style="margin-bottom:6px">
<button class="btn btn-sm btn-green" onclick="clearanceRuleCreate()">+ ADD RULE</button>
</div>
</div>
</div>
<!-- History -->
<div style="margin-top:20px">
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--dim);margin-bottom:10px">DECISION HISTORY</div>
<div id="clearance-history-list"><div class="loading">LOADING...</div></div>
</div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /app -->
@@ -1772,6 +1893,7 @@ function loadTab(tab) {
outbox: loadOutbox,
missions: loadMissions,
directives: loadDirectives,
clearance: loadClearance,
vision: loadVision,
guardian: loadGuardian,
tasks: loadTasks,
@@ -3820,6 +3942,166 @@ async function syncCalNow() {
});
}
// ── CLEARANCE PROTOCOL ────────────────────────────────────────────────────────
async function loadClearance() {
const [pending, rules, history] = await Promise.all([
api('clearance_pending'),
api('clearance_rules'),
api('clearance_history&limit=30'),
]);
const pList = Array.isArray(pending) ? pending : [];
const rList = Array.isArray(rules) ? rules : [];
const hList = Array.isArray(history) ? history : [];
document.getElementById('clearance-badge').textContent =
pList.length ? pList.length + ' PENDING' : 'ALL CLEAR';
// Pending
const pelEl = document.getElementById('clearance-pending-list');
if (!pList.length) {
pelEl.innerHTML = '<div class="empty-state">No pending requests</div>';
} else {
pelEl.innerHTML = pList.map(cr => {
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload||'{}') : (cr.job_payload||{});
const ts = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
const riskColor = {critical:'var(--red)',high:'var(--yellow)',medium:'var(--orange)'}[cr.risk_level] || 'var(--dim)';
return `<div style="border:1px solid ${riskColor};border-radius:6px;padding:12px;margin-bottom:8px;background:rgba(255,34,68,0.03)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-family:var(--mono);font-size:0.7rem;color:${riskColor};font-weight:bold">#${cr.id} ${esc(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
<span style="margin-left:auto;font-family:var(--mono);font-size:0.6rem;color:var(--dim)">${ts}</span>
</div>
<div style="font-family:var(--mono);font-size:0.6rem;color:var(--text);margin-bottom:6px">${esc(cr.description||'No description')}</div>
<div style="font-family:var(--mono);font-size:0.55rem;color:var(--dim);margin-bottom:8px;word-break:break-all">Payload: ${esc(JSON.stringify(pl))}</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm btn-green" onclick="clearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
<button class="btn btn-sm btn-red" onclick="clearanceDecide(${cr.id},'deny')">✕ DENY</button>
</div>
</div>`;
}).join('');
}
// Rules
const rElEl = document.getElementById('clearance-rules-list');
if (!rList.length) {
rElEl.innerHTML = '<div class="empty-state">No rules configured</div>';
} else {
rElEl.innerHTML = '<table class="tbl"><thead><tr><th>JOB TYPE</th><th>RISK</th><th>APPROVAL</th><th>AUTO (MIN)</th><th>ENABLED</th><th></th></tr></thead><tbody>' +
rList.map(r => {
const enLabel = r.enabled ? '<span style="color:var(--green)">ON</span>' : '<span style="color:var(--dim)">OFF</span>';
const reqLabel = r.require_approval ? 'REQUIRED' : 'BYPASS';
const riskColor = {critical:'var(--red)',high:'var(--yellow)',medium:'var(--orange)'}[r.risk_level] || 'var(--dim)';
return `<tr>
<td style="font-family:var(--mono);font-size:0.65rem">${esc(r.job_type)}</td>
<td style="color:${riskColor};font-family:var(--mono);font-size:0.6rem">${r.risk_level.toUpperCase()}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${reqLabel}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${r.auto_approve_after_min || '—'}</td>
<td>${enLabel}</td>
<td><div style="display:flex;gap:4px">
<button class="btn btn-xs" onclick="clearanceRuleToggle(${r.id},${r.enabled?0:1})">${r.enabled?'DISABLE':'ENABLE'}</button>
<button class="btn btn-xs btn-yellow" onclick="clearanceRuleEdit(${r.id})">EDIT</button>
</div></td>
</tr>`;
}).join('') + '</tbody></table>';
}
// History
const hEl = document.getElementById('clearance-history-list');
const decided = hList.filter(h => h.status !== 'pending').slice(0,20);
if (!decided.length) {
hEl.innerHTML = '<div class="empty-state">No history yet</div>';
} else {
const statusColor = {approved:'var(--green)',denied:'var(--red)',expired:'var(--dim)',auto_approved:'var(--cyan)'};
hEl.innerHTML = '<table class="tbl"><thead><tr><th>#</th><th>JOB TYPE</th><th>RISK</th><th>STATUS</th><th>DECIDED BY</th><th>DECIDED AT</th><th>NOTE</th></tr></thead><tbody>' +
decided.map(h => {
const sc = statusColor[h.status] || 'var(--dim)';
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
return `<tr>
<td style="font-family:var(--mono)">${h.id}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${esc(h.job_type)}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${h.risk_level}</td>
<td style="color:${sc};font-family:var(--mono);font-size:0.6rem;font-weight:bold">${h.status.toUpperCase()}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${esc(h.decided_by||'—')}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${ts}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${esc(h.decision_note||'')}</td>
</tr>`;
}).join('') + '</tbody></table>';
}
}
async function clearanceDecide(id, action) {
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
if (!confirm(`${label} clearance request #${id}?`)) return;
let note = '';
if (action === 'deny') note = prompt('Reason for denial (optional):') || '';
const body = {decided_by: 'admin'};
if (note) body.note = note;
try {
const r = await fetch(location.href + '?action=clearance_' + action + '&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const d = await r.json();
if (d.ok || d.job_id) {
toast(label + 'D clearance #' + id, 'ok');
loadClearance();
} else {
toast('Error: ' + (d.error || d.detail || 'unknown'), 'err');
}
} catch(e) { toast('Request failed', 'err'); }
}
async function clearanceRuleToggle(id, newEnabled) {
try {
await fetch(location.href + '?action=clearance_rule_update&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({enabled: newEnabled})
});
toast(newEnabled ? 'Rule enabled' : 'Rule disabled', 'ok');
loadClearance();
} catch(e) { toast('Failed', 'err'); }
}
async function clearanceRuleEdit(id) {
// Open a simple prompt-based edit for auto_approve_after_min
const mins = prompt('Auto-approve after N minutes (blank = never require auto-approval):');
if (mins === null) return;
const body = {auto_approve_after_min: mins === '' ? null : parseInt(mins)};
try {
await fetch(location.href + '?action=clearance_rule_update&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
toast('Rule updated', 'ok');
loadClearance();
} catch(e) { toast('Failed', 'err'); }
}
async function clearanceRuleCreate() {
const jobType = document.getElementById('clr-new-type').value.trim();
if (!jobType) { toast('Job type required', 'err'); return; }
const body = {
job_type: jobType,
risk_level: document.getElementById('clr-new-risk').value,
require_approval: parseInt(document.getElementById('clr-new-req').value),
auto_approve_after_min: document.getElementById('clr-new-auto').value || null,
description: document.getElementById('clr-new-desc').value.trim(),
enabled: 1,
};
try {
const r = await fetch(location.href + '?action=clearance_rule_create', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const d = await r.json();
if (d.ok) {
toast('Rule created', 'ok');
document.getElementById('clr-new-type').value = '';
document.getElementById('clr-new-desc').value = '';
document.getElementById('clr-new-auto').value = '';
loadClearance();
} else {
toast('Error: ' + (d.detail || 'unknown'), 'err');
}
} catch(e) { toast('Failed', 'err'); }
}
</script>
</body>
</html>