mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
@@ -292,6 +292,52 @@ switch ($action) {
|
||||
echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled]));
|
||||
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:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => "Unknown arc action: {$action}"]);
|
||||
|
||||
@@ -1082,6 +1082,33 @@ if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current e
|
||||
$arcJobId = null;
|
||||
|
||||
// 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 {
|
||||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||||
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) ───────────────────────────────
|
||||
if (!$reply) {
|
||||
$matched = KBEngine::match($message);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -980,6 +980,37 @@ body::after{
|
||||
.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: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-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}
|
||||
@@ -1196,6 +1227,13 @@ body::after{
|
||||
|
||||
<!-- Tab Panel -->
|
||||
<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 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-missions" onclick="switchTab('missions')">MISSIONS</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 id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1">
|
||||
<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="directives-hud"><div class="loading-shimmer"></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>
|
||||
@@ -2461,6 +2503,11 @@ function showApp(name, greeting, silent = false) {
|
||||
startGuardianPolling();
|
||||
setInterval(_pollProactiveChat, 30000);
|
||||
}, 5000);
|
||||
// Clearance banner — poll every 30s
|
||||
setTimeout(() => {
|
||||
updateClearanceBanner();
|
||||
setInterval(updateClearanceBanner, 30000);
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -3163,6 +3210,7 @@ function switchTab(name) {
|
||||
if (name === 'guardian') loadGuardian();
|
||||
if (name === 'missions') loadMissionsHud();
|
||||
if (name === 'directives') loadDirectivesHud();
|
||||
if (name === 'clearance') loadClearanceHud();
|
||||
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() {
|
||||
const [listData, metricsData] = await Promise.all([
|
||||
api('agent/list'),
|
||||
|
||||
Reference in New Issue
Block a user