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
+46
View File
@@ -292,6 +292,52 @@ switch ($action) {
echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled])); echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled]));
break; break;
// GET /api/arc?action=clearance_pending
case 'clearance_pending':
echo json_encode(arc_request('GET', '/clearance/pending'));
break;
// GET /api/arc?action=clearance_history
case 'clearance_history':
$limit = (int)($_GET['limit'] ?? 50);
echo json_encode(arc_request('GET', "/clearance/history?limit={$limit}"));
break;
// POST /api/arc?action=clearance_approve&id=123 body: { decided_by: "..." }
case 'clearance_approve':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
$decided_by = $data['decided_by'] ?? 'admin';
echo json_encode(arc_request('POST', "/clearance/{$id}/approve", ['decided_by' => $decided_by]));
break;
// POST /api/arc?action=clearance_deny&id=123 body: { decided_by: "...", note: "..." }
case 'clearance_deny':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
$decided_by = $data['decided_by'] ?? 'admin';
$note = $data['note'] ?? '';
echo json_encode(arc_request('POST', "/clearance/{$id}/deny", ['decided_by' => $decided_by, 'note' => $note]));
break;
// GET /api/arc?action=clearance_rules
case 'clearance_rules':
echo json_encode(arc_request('GET', '/clearance/rules'));
break;
// PUT /api/arc?action=clearance_rule_update&id=123 body: { require_approval: 0|1, auto_approve_after_min: N, ... }
case 'clearance_rule_update':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
unset($data['id']);
echo json_encode(arc_request('PUT', "/clearance/rules/{$id}", $data));
break;
// POST /api/arc?action=clearance_rule_create body: { job_type, risk_level, require_approval, ... }
case 'clearance_rule_create':
echo json_encode(arc_request('POST', '/clearance/rules', $data));
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => "Unknown arc action: {$action}"]); echo json_encode(['error' => "Unknown arc action: {$action}"]);
+86
View File
@@ -1082,6 +1082,33 @@ if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current e
$arcJobId = null; $arcJobId = null;
// Helper: submit job to Arc Reactor // Helper: submit job to Arc Reactor
function arcPost(string $path, array $body): ?array {
$ch = curl_init('http://127.0.0.1:7474' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
return $res;
}
function arcGet(string $path): ?array {
$ch = curl_init('http://127.0.0.1:7474' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
return $res;
}
function arcSubmitJob(string $type, array $payload, string $sessionId): ?array { function arcSubmitJob(string $type, array $payload, string $sessionId): ?array {
$ch = curl_init('http://127.0.0.1:7474/job'); $ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -1395,6 +1422,65 @@ if (!$reply) {
} }
} }
// ── Tier 0.9j: Clearance Protocol — approve/deny voice commands ─────────────
if (!$reply) {
// "approve clearance 5", "authorize clearance", "approve all clearance"
if (preg_match('/^(?:jarvis[,\s]+)?(?:approve|authorize|grant)\s+(?:all\s+)?clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) {
$crId = isset($m[1]) && $m[1] ? (int)$m[1] : null;
if ($crId) {
$resp = arcPost('/clearance/' . $crId . '/approve', ['decided_by' => 'voice']);
if (isset($resp['ok']) && $resp['ok']) {
$reply = "◈ Clearance request #{$crId} authorized, {$userAddr}. Job dispatched.";
} else {
$reply = "Clearance #{$crId} not found or already decided.";
}
} else {
// Approve all pending
$pending = arcGet('/clearance/pending') ?: [];
if (empty($pending)) {
$reply = "No pending clearance requests, {$userAddr}.";
} else {
$approved = 0;
foreach ($pending as $cr) {
$resp = arcPost('/clearance/' . $cr['id'] . '/approve', ['decided_by' => 'voice']);
if (isset($resp['ok']) && $resp['ok']) $approved++;
}
$reply = "◈ Authorized {$approved} clearance request" . ($approved !== 1 ? 's' : '') . ", {$userAddr}.";
}
}
$source = 'arc:clearance_approve';
}
}
if (!$reply) {
// "deny clearance 5", "reject clearance 5"
if (preg_match('/^(?:jarvis[,\s]+)?(?:deny|reject|refuse)\s+clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) {
$crId = isset($m[1]) && $m[1] ? (int)$m[1] : null;
if ($crId) {
$resp = arcPost('/clearance/' . $crId . '/deny', ['decided_by' => 'voice', 'note' => 'denied by voice command']);
$reply = isset($resp['ok']) && $resp['ok']
? "Clearance #{$crId} denied, {$userAddr}."
: "Clearance #{$crId} not found or already decided.";
} else {
$reply = "Which clearance request should I deny? Say: deny clearance [number].";
}
$source = 'arc:clearance_deny';
}
}
if (!$reply) {
// "any pending clearance", "clearance status", "show clearance"
if (preg_match('/^(?:jarvis[,\s]+)?(?:(?:any\s+|show\s+)?pending\s+clearance|clearance\s+(?:status|requests?|pending|queue))/i', $message)) {
$pending = arcGet('/clearance/pending') ?: [];
$count = count($pending);
if ($count === 0) {
$reply = "No pending clearance requests, {$userAddr}. All clear.";
} else {
$list = array_map(fn($cr) => "#{$cr['id']} {$cr['job_type']} ({$cr['risk_level']})", $pending);
$reply = "{$count} pending clearance request" . ($count !== 1 ? 's' : '') . ": " . implode(', ', $list) . ". Say 'approve clearance [number]' to authorize.";
}
$source = 'arc:clearance_status';
}
}
// ── Tier 1: Intent Engine (instant, no LLM) ─────────────────────────────── // ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
if (!$reply) { if (!$reply) {
$matched = KBEngine::match($message); $matched = KBEngine::match($message);
+282
View File
@@ -763,6 +763,76 @@ if ($action) {
$raw = curl_exec($ch); curl_close($ch); $raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]); 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 ────────────────────────────────────────────────── // ── VISION PROTOCOL ──────────────────────────────────────────────────
case 'vision_list': case 'vision_list':
$limit = min((int)($_GET['limit'] ?? 30), 100); $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="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="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="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-section">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</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> <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>
</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><!-- /content -->
</div><!-- /main --> </div><!-- /main -->
</div><!-- /app --> </div><!-- /app -->
@@ -1772,6 +1893,7 @@ function loadTab(tab) {
outbox: loadOutbox, outbox: loadOutbox,
missions: loadMissions, missions: loadMissions,
directives: loadDirectives, directives: loadDirectives,
clearance: loadClearance,
vision: loadVision, vision: loadVision,
guardian: loadGuardian, guardian: loadGuardian,
tasks: loadTasks, 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> </script>
</body> </body>
</html> </html>
+201
View File
@@ -980,6 +980,37 @@ body::after{
.mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite} .mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite}
.mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px} .mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.mission-new-btn:hover{background:rgba(0,212,255,0.12)} .mission-new-btn:hover{background:rgba(0,212,255,0.12)}
/* ── CLEARANCE PROTOCOL ──────────────────────────────────────────── */
#clearance-banner{display:none;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:var(--r);padding:6px 10px;margin:0 0 8px;font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:#ff6680;animation:borderPulse 2s ease-in-out infinite}
@keyframes borderPulse{0%,100%{border-color:rgba(255,34,68,0.4)}50%{border-color:rgba(255,34,68,0.9)}}
#clearance-banner.active{display:flex;align-items:center;gap:8px}
#clearance-banner .clr-count{background:rgba(255,34,68,0.3);border-radius:3px;padding:1px 5px;font-size:0.6rem;color:#ff2244}
#clearance-banner .clr-view{margin-left:auto;cursor:pointer;color:#ff6680;text-decoration:underline}
.clr-card{background:rgba(255,34,68,0.04);border:1px solid rgba(255,34,68,0.3);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.clr-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.clr-card-head:hover{background:rgba(255,34,68,0.06)}
.clr-card-type{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;color:#ff8899}
.clr-card-risk{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;border:1px solid}
.clr-card-risk.critical{color:#ff2244;border-color:rgba(255,34,68,0.5)}
.clr-card-risk.high{color:#ffd700;border-color:rgba(255,215,0,0.4)}
.clr-card-risk.medium{color:#ff9900;border-color:rgba(255,153,0,0.4)}
.clr-card-body{display:none;padding:8px 10px 10px;border-top:1px solid rgba(255,34,68,0.2)}
.clr-card.open .clr-card-body{display:block}
.clr-card-desc{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin-bottom:8px;line-height:1.5;white-space:pre-wrap}
.clr-action-bar{display:flex;gap:6px;margin-top:8px}
.clr-approve-btn{flex:1;background:rgba(0,255,136,0.08);border:1px solid rgba(0,255,136,0.4);border-radius:3px;padding:4px 8px;color:#00ff88;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
.clr-approve-btn:hover{background:rgba(0,255,136,0.18)}
.clr-deny-btn{flex:1;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:3px;padding:4px 8px;color:#ff2244;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
.clr-deny-btn:hover{background:rgba(255,34,68,0.18)}
.clr-history-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
.clr-status-approved{color:#00ff88}.clr-status-denied{color:#ff2244}.clr-status-pending{color:#ffd700}.clr-status-expired{color:rgba(255,255,255,0.3)}
.clr-rule-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
.clr-rule-type{flex:1;color:var(--cyan)}
.clr-rule-toggle{cursor:pointer;padding:2px 6px;border-radius:2px;font-size:0.48rem;border:1px solid}
.clr-rule-enabled{color:#00ff88;border-color:rgba(0,255,136,0.4)}
.clr-rule-disabled{color:rgba(255,255,255,0.3);border-color:rgba(255,255,255,0.15)}
.clr-admin-btn{width:100%;background:rgba(255,34,68,0.06);border:1px solid rgba(255,34,68,0.3);border-radius:4px;padding:5px;color:#ff6680;font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.clr-admin-btn:hover{background:rgba(255,34,68,0.12)}
/* ── INTEL PROTOCOL — research result cards ──────────────────────── */ /* ── INTEL PROTOCOL — research result cards ──────────────────────── */
.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden} .intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none} .intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
@@ -1196,6 +1227,13 @@ body::after{
<!-- Tab Panel --> <!-- Tab Panel -->
<div class="panel" style="flex:1;overflow:hidden;display:flex;flex-direction:column"> <div class="panel" style="flex:1;overflow:hidden;display:flex;flex-direction:column">
<!-- Clearance Alert Banner -->
<div id="clearance-banner">
<span>◈ CLEARANCE REQUIRED —</span>
<span class="clr-count" id="clr-banner-count">0</span>
<span>PENDING AUTHORIZATION</span>
<span class="clr-view" onclick="switchTab('clearance')">VIEW →</span>
</div>
<div class="tab-bar"> <div class="tab-bar">
<div class="tab active" onclick="switchTab('ha')">HOME</div> <div class="tab active" onclick="switchTab('ha')">HOME</div>
@@ -1208,6 +1246,7 @@ body::after{
<div class="tab" id="tab-btn-guardian" onclick="switchTab('guardian')">GUARDIAN</div> <div class="tab" id="tab-btn-guardian" onclick="switchTab('guardian')">GUARDIAN</div>
<div class="tab" id="tab-btn-missions" onclick="switchTab('missions')">MISSIONS</div> <div class="tab" id="tab-btn-missions" onclick="switchTab('missions')">MISSIONS</div>
<div class="tab" id="tab-btn-directives" onclick="switchTab('directives')">DIRECTIVES</div> <div class="tab" id="tab-btn-directives" onclick="switchTab('directives')">DIRECTIVES</div>
<div class="tab" id="tab-btn-clearance" onclick="switchTab('clearance')">CLEARANCE <span id="clr-tab-badge" style="display:none;background:#ff2244;color:#fff;border-radius:3px;padding:0 4px;font-size:0.5rem;margin-left:2px"></span></div>
</div> </div>
<div id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1"> <div id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="vm-list"><div class="loading-shimmer"></div></div> <div id="vm-list"><div class="loading-shimmer"></div></div>
@@ -1241,6 +1280,9 @@ body::after{
<div id="tab-directives" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0"> <div id="tab-directives" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="directives-hud"><div class="loading-shimmer"></div></div> <div id="directives-hud"><div class="loading-shimmer"></div></div>
</div> </div>
<div id="tab-clearance" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="clearance-hud"><div class="loading-shimmer"></div></div>
</div>
</div> </div>
</div> </div>
@@ -2461,6 +2503,11 @@ function showApp(name, greeting, silent = false) {
startGuardianPolling(); startGuardianPolling();
setInterval(_pollProactiveChat, 30000); setInterval(_pollProactiveChat, 30000);
}, 5000); }, 5000);
// Clearance banner — poll every 30s
setTimeout(() => {
updateClearanceBanner();
setInterval(updateClearanceBanner, 30000);
}, 6000);
} }
async function logout() { async function logout() {
@@ -3163,6 +3210,7 @@ function switchTab(name) {
if (name === 'guardian') loadGuardian(); if (name === 'guardian') loadGuardian();
if (name === 'missions') loadMissionsHud(); if (name === 'missions') loadMissionsHud();
if (name === 'directives') loadDirectivesHud(); if (name === 'directives') loadDirectivesHud();
if (name === 'clearance') loadClearanceHud();
if (name === 'alerts') loadAlerts(); if (name === 'alerts') loadAlerts();
} }
@@ -4360,6 +4408,159 @@ async function hudDirectiveReview(id) {
} }
} }
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
const _clrOpenCards = new Set();
async function updateClearanceBanner() {
try {
const pending = await api('arc?action=clearance_pending');
const list = Array.isArray(pending) ? pending : [];
const count = list.length;
const banner = document.getElementById('clearance-banner');
const badge = document.getElementById('clr-tab-badge');
const bcount = document.getElementById('clr-banner-count');
if (banner) {
if (count > 0) {
banner.classList.add('active');
if (bcount) bcount.textContent = count;
} else {
banner.classList.remove('active');
}
}
if (badge) {
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
else badge.style.display = 'none';
}
} catch(e) {}
}
async function loadClearanceHud() {
const el = document.getElementById('clearance-hud');
if (!el) return;
try {
const [pendingRes, rulesRes, historyRes] = await Promise.all([
api('arc?action=clearance_pending'),
api('arc?action=clearance_rules'),
api('arc?action=clearance_history&limit=20')
]);
const pending = Array.isArray(pendingRes) ? pendingRes : [];
const rules = Array.isArray(rulesRes) ? rulesRes : [];
const history = Array.isArray(historyRes) ? historyRes : [];
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
// Pending requests
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
if (!pending.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
} else {
for (const cr of pending) {
const isOpen = _clrOpenCards.has(cr.id);
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
</div>
<div class="clr-card-body">
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
Payload: ${escHtml(JSON.stringify(pl))}
</div>
<div class="clr-action-bar">
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')">✕ DENY</button>
</div>
</div>
</div>`;
}
}
// Rules
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
if (!rules.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
for (const r of rules) {
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
const enLabel = r.enabled ? 'ON' : 'OFF';
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
html += `<div class="clr-rule-row">
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
</div>`;
}
html += '</div>';
}
// Recent history
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
if (!recentDecided.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
for (const h of recentDecided) {
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
html += `<div class="clr-history-row">
<span class="clr-status-${h.status}"></span>
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
</div>`;
}
html += '</div>';
}
el.innerHTML = html;
await updateClearanceBanner();
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
}
}
function toggleClrCard(id) {
const card = document.getElementById('clr-card-' + id);
if (!card) return;
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
else _clrOpenCards.add(id);
card.classList.toggle('open');
}
async function hudClearanceDecide(id, action) {
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
if (!confirm(`${label} clearance request #${id}?`)) return;
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
try {
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
const msg = action === 'approve'
? `◈ Clearance #${id} authorized. Job dispatched.`
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
addMessage('jarvis', msg);
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
await loadClearanceHud();
} catch(e) {
addMessage('system', 'Clearance action failed.');
}
}
async function hudClearanceRuleToggle(id, newEnabled) {
try {
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
await loadClearanceHud();
} catch(e) {}
}
async function loadAgents() { async function loadAgents() {
const [listData, metricsData] = await Promise.all([ const [listData, metricsData] = await Promise.all([
api('agent/list'), api('agent/list'),