Merge pull request #100 from jmagly/pr80-delivery
Deliver repaired PR #80 approval receipts
This commit is contained in:
3 files changed
+387
-19
No files matched your search
+225
-9
@@ -3390,6 +3390,10 @@
|
|||||||
<div class="nav-item" data-page="live-scan">
|
<div class="nav-item" data-page="live-scan">
|
||||||
<span class="icon">📡</span> Live Scan
|
<span class="icon">📡</span> Live Scan
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" data-page="receipts">
|
||||||
|
<span class="icon">🧾</span> Scope Receipts
|
||||||
|
<span class="badge" id="pendingReceiptCount">0</span>
|
||||||
|
</div>
|
||||||
<div class="nav-item" data-page="operators">
|
<div class="nav-item" data-page="operators">
|
||||||
<span class="icon">🤖</span> Operatives
|
<span class="icon">🤖</span> Operatives
|
||||||
<span class="badge" id="activeOperatorCount">0</span>
|
<span class="badge" id="activeOperatorCount">0</span>
|
||||||
@@ -3712,6 +3716,10 @@
|
|||||||
<button type="button" onclick="navigateTo('ctf-range')">Range</button>
|
<button type="button" onclick="navigateTo('ctf-range')">Range</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="hunt-panel">
|
||||||
|
<div class="ops-card-title"><span>Scope Receipts</span><span id="scopeReceiptCount">0 pending</span></div>
|
||||||
|
<div id="scopeReceiptFeed" style="max-height:180px;overflow-y:auto;font-family:'JetBrains Mono',monospace;font-size:10px;line-height:1.55;"><span style="color:#5a5f67;font-size:10.5px;">No pending scope receipts.</span></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4303,6 +4311,28 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scope Receipts - explicit approvals for external/live execution -->
|
||||||
|
<div class="page" id="page-receipts">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:18px;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px;color:var(--accent);text-transform:uppercase;letter-spacing:2px;margin-bottom:6px;">ScopeGuard</div>
|
||||||
|
<div style="font-size:24px;font-weight:800;color:var(--text-primary);line-height:1.15;">Scope Receipts</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="refreshReceiptsPage()">Refresh</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="approveAllPendingReceipts()">Approve Pending</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="receiptsSummary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:14px;"></div>
|
||||||
|
<section style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;overflow:hidden;">
|
||||||
|
<div style="padding:12px 14px;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center;gap:10px;">
|
||||||
|
<div style="font-size:11px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:1px;">Approval Requests</div>
|
||||||
|
<div id="receiptsCount" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);">0 receipts</div>
|
||||||
|
</div>
|
||||||
|
<div id="receiptsList" style="max-height:min(62vh,680px);overflow:auto;"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Operators - Tactical Unit Command -->
|
<!-- Operators - Tactical Unit Command -->
|
||||||
<div class="page" id="page-operators">
|
<div class="page" id="page-operators">
|
||||||
<!-- ═══ OPERATIVES ROSTER — click any specialist to view + edit its prompt / tools / config ═══ -->
|
<!-- ═══ OPERATIVES ROSTER — click any specialist to view + edit its prompt / tools / config ═══ -->
|
||||||
@@ -6022,12 +6052,18 @@ tempest.operators.<span class="function">spawn</span>(<span class="string">'scan
|
|||||||
this._eventSource.addEventListener('connected', () => {
|
this._eventSource.addEventListener('connected', () => {
|
||||||
console.log('[BackendDispatch] SSE connected');
|
console.log('[BackendDispatch] SSE connected');
|
||||||
try { fetchToolApprovals(); } catch {}
|
try { fetchToolApprovals(); } catch {}
|
||||||
|
try { fetchScopeReceipts(); } catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Capability-approval gate: every gated tool decision (allow/deny) + spicy warning.
|
// Capability-approval gate: every gated tool decision (allow/deny) + spicy warning.
|
||||||
this._eventSource.addEventListener('arsenal.approval', (e) => {
|
this._eventSource.addEventListener('arsenal.approval', (e) => {
|
||||||
try { pushApprovalAuditEvent(JSON.parse(e.data)); } catch {}
|
try { pushApprovalAuditEvent(JSON.parse(e.data)); } catch {}
|
||||||
});
|
});
|
||||||
|
['approval.requested', 'approval.approved', 'approval.rejected'].forEach((eventName) => {
|
||||||
|
this._eventSource.addEventListener(eventName, () => {
|
||||||
|
try { fetchScopeReceipts(); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this._eventSource.addEventListener('operator:spawned', (e) => {
|
this._eventSource.addEventListener('operator:spawned', (e) => {
|
||||||
try {
|
try {
|
||||||
@@ -7238,14 +7274,115 @@ Correlating findings across agents, identifying patterns, producing actionable i
|
|||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
|
const ReceiptsState = { approvals: [] };
|
||||||
|
|
||||||
|
function receiptStatusColor(status) {
|
||||||
|
const s = String(status || '').toLowerCase();
|
||||||
|
if (s === 'approved') return '#00ff88';
|
||||||
|
if (s === 'pending') return '#ffaa00';
|
||||||
|
if (s === 'rejected' || s === 'expired') return '#ff6666';
|
||||||
|
return 'var(--text-secondary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.refreshReceiptsPage = async function refreshReceiptsPage() {
|
||||||
|
const data = await T3MP3ST_API.get('/api/approvals');
|
||||||
|
ReceiptsState.approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
||||||
|
renderReceiptsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.approveReceipt = async function approveReceipt(id) {
|
||||||
|
const receipt = ReceiptsState.approvals.find(function(a){ return a.id === id; });
|
||||||
|
await T3MP3ST_API.post('/api/approvals/' + id + '/approve', {});
|
||||||
|
await window.refreshReceiptsPage();
|
||||||
|
if (window.toast) toast('Receipt approved: ' + id, 'success');
|
||||||
|
if (receipt?.action === 'autonomous_execution' && window.__t3mpAdmiralPendingRetry && typeof window.retryPendingAdmiralLaunch === 'function') {
|
||||||
|
window.retryPendingAdmiralLaunch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.rejectReceipt = async function rejectReceipt(id) {
|
||||||
|
await T3MP3ST_API.post('/api/approvals/' + id + '/reject', {});
|
||||||
|
await window.refreshReceiptsPage();
|
||||||
|
if (window.toast) toast('Receipt rejected: ' + id, 'warning');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.approveAllPendingReceipts = async function approveAllPendingReceipts() {
|
||||||
|
const pending = ReceiptsState.approvals.filter(function(a){ return a.status === 'pending'; });
|
||||||
|
await Promise.all(pending.map(function(a){ return T3MP3ST_API.post('/api/approvals/' + a.id + '/approve', {}); }));
|
||||||
|
await window.refreshReceiptsPage();
|
||||||
|
if (window.toast) toast('Approved ' + pending.length + ' pending receipt(s)', pending.length ? 'success' : 'info');
|
||||||
|
if (pending.some(function(a){ return a.action === 'autonomous_execution'; }) && window.__t3mpAdmiralPendingRetry && typeof window.retryPendingAdmiralLaunch === 'function') {
|
||||||
|
window.retryPendingAdmiralLaunch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderReceiptsPage() {
|
||||||
|
const approvals = ReceiptsState.approvals || [];
|
||||||
|
const pending = approvals.filter(function(a){ return a.status === 'pending'; }).length;
|
||||||
|
const approved = approvals.filter(function(a){ return a.status === 'approved'; }).length;
|
||||||
|
const closed = approvals.filter(function(a){ return a.status === 'expired' || a.status === 'rejected'; }).length;
|
||||||
|
const badge = document.getElementById('pendingReceiptCount');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = String(pending);
|
||||||
|
badge.style.background = pending ? '#ffaa00' : 'rgba(255,255,255,0.12)';
|
||||||
|
badge.style.color = pending ? '#1b1200' : '';
|
||||||
|
}
|
||||||
|
const summary = document.getElementById('receiptsSummary');
|
||||||
|
if (summary) {
|
||||||
|
const cells = [
|
||||||
|
['Pending', pending, pending ? '#ffaa00' : 'var(--text-secondary)'],
|
||||||
|
['Approved', approved, '#00ff88'],
|
||||||
|
['Closed', closed, closed ? '#ff6666' : 'var(--text-secondary)'],
|
||||||
|
['Total', approvals.length, '#00aaff']
|
||||||
|
];
|
||||||
|
summary.innerHTML = cells.map(function(cell){
|
||||||
|
return '<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;padding:12px;min-height:74px;">'
|
||||||
|
+ '<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">' + escapeHtml(cell[0]) + '</div>'
|
||||||
|
+ '<div style="font-family:"JetBrains Mono",monospace;font-size:18px;color:' + cell[2] + ';">' + escapeHtml(cell[1]) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
const count = document.getElementById('receiptsCount');
|
||||||
|
if (count) count.textContent = approvals.length + ' receipts';
|
||||||
|
const list = document.getElementById('receiptsList');
|
||||||
|
if (!list) return;
|
||||||
|
if (!approvals.length) {
|
||||||
|
list.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px;">No receipts yet. Live external actions will create target-scoped approval requests here.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = approvals.map(function(a){
|
||||||
|
const color = receiptStatusColor(a.status);
|
||||||
|
const pendingActions = a.status === 'pending'
|
||||||
|
? '<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;"><button class="btn btn-primary btn-sm" onclick="approveReceipt("' + escapeHtml(a.id) + '")">Approve</button><button class="btn btn-secondary btn-sm" onclick="rejectReceipt("' + escapeHtml(a.id) + '")">Reject</button></div>'
|
||||||
|
: '';
|
||||||
|
return '<div style="padding:12px 14px;border-bottom:1px solid rgba(255,255,255,0.06);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:start;">'
|
||||||
|
+ '<div style="min-width:0;">'
|
||||||
|
+ '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px;"><span style="font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--text-muted);">' + escapeHtml(a.id) + '</span><span style="font-family:"JetBrains Mono",monospace;font-size:10px;color:' + color + ';text-transform:uppercase;">' + escapeHtml(a.status || 'unknown') + '</span></div>'
|
||||||
|
+ '<div style="font-size:13px;color:var(--text-primary);font-weight:700;overflow-wrap:anywhere;">' + escapeHtml(a.target || '-') + '</div>'
|
||||||
|
+ '<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">' + escapeHtml(a.action || '-') + ' · ' + escapeHtml(a.requestedBy || '-') + '</div>'
|
||||||
|
+ '<div style="font-size:11px;color:var(--text-secondary);margin-top:7px;line-height:1.45;overflow-wrap:anywhere;">' + escapeHtml(a.reason || '') + '</div>'
|
||||||
|
+ (a.expiresAt ? '<div style="font-size:10px;color:var(--text-muted);margin-top:7px;">Expires ' + escapeHtml(new Date(a.expiresAt).toLocaleString()) + '</div>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ pendingActions
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.getElementById('page-receipts')?.classList.contains('active')) {
|
||||||
|
window.refreshReceiptsPage?.();
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
function navigateTo(page) {
|
function navigateTo(page) {
|
||||||
document.querySelectorAll('.nav-item').forEach(i => i.classList.toggle('active', i.dataset.page === page));
|
document.querySelectorAll('.nav-item').forEach(i => i.classList.toggle('active', i.dataset.page === page));
|
||||||
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
|
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
|
||||||
const titles = { warroom: 'War Room', 'live-scan': 'Live Scan', operators: 'Operatives', evidence: 'Evidence Vault', arsenal: 'Arsenal', terminal: 'Terminal', benchmarks: 'Benchmarks', configs: 'Config Library', 'ctf-range': 'CTF Range', general: 'Op Admiral', selfimprove: 'Self-Improvement', settings: 'Settings', about: 'About' };
|
const titles = { warroom: 'War Room', 'live-scan': 'Live Scan', receipts: 'Scope Receipts', operators: 'Operatives', evidence: 'Evidence Vault', arsenal: 'Arsenal', terminal: 'Terminal', benchmarks: 'Benchmarks', configs: 'Config Library', 'ctf-range': 'CTF Range', general: 'Op Admiral', selfimprove: 'Self-Improvement', settings: 'Settings', about: 'About' };
|
||||||
document.getElementById('pageTitle').textContent = titles[page] || 'War Room';
|
document.getElementById('pageTitle').textContent = titles[page] || 'War Room';
|
||||||
document.getElementById('sidebar').classList.remove('open');
|
document.getElementById('sidebar').classList.remove('open');
|
||||||
if (page === 'live-scan') setTimeout(() => window.refreshLiveScanPage?.(), 50);
|
if (page === 'live-scan') setTimeout(() => window.refreshLiveScanPage?.(), 50);
|
||||||
|
if (page === 'receipts') setTimeout(() => window.refreshReceiptsPage?.(), 50);
|
||||||
if (page === 'operators' && typeof window.loadOperativesRoster === 'function') setTimeout(window.loadOperativesRoster, 80);
|
if (page === 'operators' && typeof window.loadOperativesRoster === 'function') setTimeout(window.loadOperativesRoster, 80);
|
||||||
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
|
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
|
||||||
if (page === 'selfimprove' && typeof window.renderSelfImprove === 'function') setTimeout(window.renderSelfImprove, 60);
|
if (page === 'selfimprove' && typeof window.renderSelfImprove === 'function') setTimeout(window.renderSelfImprove, 60);
|
||||||
@@ -24647,6 +24784,59 @@ Be specific and actionable. Format as structured JSON.`;
|
|||||||
if (data.audit.length) { feed.dataset.live = '1'; feed.innerHTML = ''; data.audit.slice(-40).forEach(rec => pushApprovalAuditEvent(rec, true)); }
|
if (data.audit.length) { feed.dataset.live = '1'; feed.innerHTML = ''; data.audit.slice(-40).forEach(rec => pushApprovalAuditEvent(rec, true)); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function fetchScopeReceipts() {
|
||||||
|
try {
|
||||||
|
if (!T3MP3ST_API.baseUrl) return;
|
||||||
|
const r = await fetch(`${T3MP3ST_API.baseUrl}/api/approvals`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
renderScopeReceipts(data.approvals || []);
|
||||||
|
ReceiptsState.approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
||||||
|
renderReceiptsPage();
|
||||||
|
} catch (e) { /* offline — panel stays in its last known state */ }
|
||||||
|
}
|
||||||
|
function renderScopeReceipts(approvals) {
|
||||||
|
const feed = document.getElementById('scopeReceiptFeed');
|
||||||
|
const count = document.getElementById('scopeReceiptCount');
|
||||||
|
if (!feed) return;
|
||||||
|
const pending = (approvals || []).filter(a => a && a.status === 'pending');
|
||||||
|
const recent = (approvals || []).slice().sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, 12);
|
||||||
|
if (count) count.textContent = `${pending.length} pending`;
|
||||||
|
if (!recent.length) {
|
||||||
|
feed.innerHTML = '<span style="color:#5a5f67;font-size:10.5px;">No pending scope receipts.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feed.innerHTML = recent.map(a => {
|
||||||
|
const color = receiptStatusColor(a.status);
|
||||||
|
const pendingActions = a.status === 'pending'
|
||||||
|
? `<div style="display:flex;gap:5px;margin-top:5px;"><button onclick="approveScopeReceipt('${escapeHtml(a.id)}')" style="padding:2px 7px;border:1px solid rgba(0,255,136,0.4);background:rgba(0,255,136,0.08);color:#00ff88;border-radius:4px;font-size:10px;cursor:pointer;">approve</button><button onclick="rejectScopeReceipt('${escapeHtml(a.id)}')" style="padding:2px 7px;border:1px solid rgba(255,102,102,0.4);background:rgba(255,102,102,0.08);color:#ff7777;border-radius:4px;font-size:10px;cursor:pointer;">reject</button></div>`
|
||||||
|
: '';
|
||||||
|
return `<div style="padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.04);">
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:6px;"><span style="color:${color};text-transform:uppercase;">${escapeHtml(a.status || 'unknown')}</span><span style="color:#555;">${escapeHtml(a.id || '')}</span></div>
|
||||||
|
<div style="color:#aab;font-size:10.5px;overflow-wrap:anywhere;">${escapeHtml(a.action || '')} · ${escapeHtml(a.target || '')}</div>
|
||||||
|
<div style="color:#777;font-size:10px;line-height:1.35;overflow-wrap:anywhere;">${escapeHtml(a.reason || '')}</div>
|
||||||
|
${pendingActions}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
window.approveScopeReceipt = async function approveScopeReceipt(id) {
|
||||||
|
try {
|
||||||
|
await T3MP3ST_API.post(`/api/approvals/${id}/approve`, { approvedBy: 'local-operator' });
|
||||||
|
try { addIntel('SCOPEGUARD', `Approved receipt ${id}`, 'success'); } catch (e) {}
|
||||||
|
await fetchScopeReceipts();
|
||||||
|
} catch (e) {
|
||||||
|
try { toast('Receipt approval failed', 'error'); } catch (err) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.rejectScopeReceipt = async function rejectScopeReceipt(id) {
|
||||||
|
try {
|
||||||
|
await T3MP3ST_API.post(`/api/approvals/${id}/reject`, {});
|
||||||
|
try { addIntel('SCOPEGUARD', `Rejected receipt ${id}`, 'warning'); } catch (e) {}
|
||||||
|
await fetchScopeReceipts();
|
||||||
|
} catch (e) {
|
||||||
|
try { toast('Receipt rejection failed', 'error'); } catch (err) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
function pushApprovalAuditEvent(rec, silent) {
|
function pushApprovalAuditEvent(rec, silent) {
|
||||||
const feed = document.getElementById('approvalAuditFeed');
|
const feed = document.getElementById('approvalAuditFeed');
|
||||||
if (!feed || !rec) return;
|
if (!feed || !rec) return;
|
||||||
@@ -24690,6 +24880,7 @@ Be specific and actionable. Format as structured JSON.`;
|
|||||||
try { addMissionLog('info', `Approval receipt requested: ${approval.id}`); } catch (e) {}
|
try { addMissionLog('info', `Approval receipt requested: ${approval.id}`); } catch (e) {}
|
||||||
try { addIntel('SCOPEGUARD', `Receipt ${approval.id} is ${approval.status}`, 'info'); } catch (e) {}
|
try { addIntel('SCOPEGUARD', `Receipt ${approval.id} is ${approval.status}`, 'info'); } catch (e) {}
|
||||||
try { toast(`Receipt requested: ${approval.id}`, 'success'); } catch (e) {}
|
try { toast(`Receipt requested: ${approval.id}`, 'success'); } catch (e) {}
|
||||||
|
try { fetchScopeReceipts(); } catch (e) {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOpsReady('approval', 'block', 'request failed');
|
setOpsReady('approval', 'block', 'request failed');
|
||||||
try { addMissionLog('warning', `Approval receipt failed: ${e.message || e}`); } catch (err) {}
|
try { addMissionLog('warning', `Approval receipt failed: ${e.message || e}`); } catch (err) {}
|
||||||
@@ -25529,6 +25720,18 @@ Be specific and actionable. Format as structured JSON.`;
|
|||||||
if (typeof updateSitRep === 'function') updateSitRep('reconnaissance', '⚓ Op Admiral is planning the ' + t.label + ' operation on ' + tgt + '…', 'ACTIVE');
|
if (typeof updateSitRep === 'function') updateSitRep('reconnaissance', '⚓ Op Admiral is planning the ' + t.label + ' operation on ' + tgt + '…', 'ACTIVE');
|
||||||
if (typeof addIntel === 'function') addIntel('ADMIRAL', '⚓ Brief handed to Op Admiral — planning ' + brief.family + ' on ' + tgt + ' (~30-90s)', 'info');
|
if (typeof addIntel === 'function') addIntel('ADMIRAL', '⚓ Brief handed to Op Admiral — planning ' + brief.family + ' on ' + tgt + ' (~30-90s)', 'info');
|
||||||
if (window.toast) toast('⚓ Op Admiral is planning the operation…', 'info');
|
if (window.toast) toast('⚓ Op Admiral is planning the operation…', 'info');
|
||||||
|
window.__t3mpAdmiralPendingRetry = false;
|
||||||
|
window.retryPendingAdmiralLaunch = function(){
|
||||||
|
if (!window.__t3mpAdmiralPendingRetry) return;
|
||||||
|
window.__t3mpAdmiralPendingRetry = false;
|
||||||
|
if (typeof addIntel === 'function') addIntel('SCOPEGUARD', 'Receipt approved — retrying Admiral launch', 'info');
|
||||||
|
if (window.toast) toast('Receipt approved — retrying Admiral launch', 'info');
|
||||||
|
setTimeout(function(){ window.admiralLaunch(); }, 50);
|
||||||
|
};
|
||||||
|
const admiralApiBase = (typeof getApiBase === 'function')
|
||||||
|
? getApiBase()
|
||||||
|
: ((typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API.baseUrl) || '');
|
||||||
|
const admiralFetch = function(path, opts){ return fetch(admiralApiBase + path, opts); };
|
||||||
const body = function(extra){ return JSON.stringify(Object.assign({ brief: brief, confirmed: true }, bb, extra || {})); };
|
const body = function(extra){ return JSON.stringify(Object.assign({ brief: brief, confirmed: true }, bb, extra || {})); };
|
||||||
// Op Admiral planning is a slow LLM call, and on success the SERVER brings up a LIVE
|
// Op Admiral planning is a slow LLM call, and on success the SERVER brings up a LIVE
|
||||||
// mission. The server budgets up to 300s for planning, so the client cap MUST exceed that
|
// mission. The server budgets up to 300s for planning, so the client cap MUST exceed that
|
||||||
@@ -25538,18 +25741,31 @@ Be specific and actionable. Format as structured JSON.`;
|
|||||||
// cautious rather than declaring failure.
|
// cautious rather than declaring failure.
|
||||||
const launchOpts = function(extra){ return { method:'POST', headers:{'Content-Type':'application/json'}, body: body(extra), signal: AbortSignal.timeout(330000) }; };
|
const launchOpts = function(extra){ return { method:'POST', headers:{'Content-Type':'application/json'}, body: body(extra), signal: AbortSignal.timeout(330000) }; };
|
||||||
try {
|
try {
|
||||||
let res = await fetch('/api/admiral/launch', launchOpts());
|
let approvalIds = [];
|
||||||
|
let res = await admiralFetch('/api/admiral/launch', launchOpts());
|
||||||
let d = await res.json();
|
let d = await res.json();
|
||||||
// autonomous-execution approval receipt — auto-grant for loopback/lab or Autonomous mode, else require it.
|
for (let approvalRound = 0; approvalRound < 5 && res.status === 403; approvalRound++) {
|
||||||
// The server gates this with HTTP 403 (blockForApproval); the HOLD gate below is 409.
|
const requested = [];
|
||||||
if (res.status === 403 && d.approval && d.approval.id) {
|
if (d.approval && d.approval.id) requested.push(d.approval);
|
||||||
|
if (Array.isArray(d.approvals)) d.approvals.forEach(function(a){ if (a && a.id) requested.push(a); });
|
||||||
|
if (!requested.length) break;
|
||||||
|
const requestedIds = requested.map(function(a){ return a.id; }).filter(Boolean);
|
||||||
|
const targets = (d.outOfScopeTargets || requested.map(function(a){ return a.target; }).filter(Boolean)).join(', ');
|
||||||
if (isLab || autonomous) {
|
if (isLab || autonomous) {
|
||||||
await fetch('/api/approvals/' + d.approval.id + '/approve', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
await Promise.all(requestedIds.map(function(id){
|
||||||
res = await fetch('/api/admiral/launch', launchOpts({ approvalId: d.approval.id }));
|
return admiralFetch('/api/approvals/' + id + '/approve', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
||||||
|
}));
|
||||||
|
approvalIds = approvalIds.concat(requestedIds).filter(function(id, idx, arr){ return id && arr.indexOf(id) === idx; });
|
||||||
|
addIntel?.('SCOPEGUARD', 'Auto-approved ' + requestedIds.length + ' receipt(s): ' + (targets || requestedIds.join(', ')), 'success');
|
||||||
|
await window.refreshReceiptsPage?.();
|
||||||
|
res = await admiralFetch('/api/admiral/launch', launchOpts({ approvalIds: approvalIds }));
|
||||||
d = await res.json();
|
d = await res.json();
|
||||||
} else {
|
continue;
|
||||||
throw new Error('Approval required for ' + tgt + ' — choose Autonomous mode, or approve in the receipts panel.');
|
|
||||||
}
|
}
|
||||||
|
window.__t3mpAdmiralPendingRetry = true;
|
||||||
|
addIntel?.('SCOPEGUARD', 'Approval required: ' + (targets || tgt) + ' — open Scope Receipts', 'warning');
|
||||||
|
navigateTo?.('receipts');
|
||||||
|
throw new Error((d.error || 'Approval required') + ' — approve ' + (targets || 'pending targets') + ' in Scope Receipts; launch will retry after approval.');
|
||||||
}
|
}
|
||||||
if (res.status === 409 && d.review && d.review.status === 'hold') throw new Error('Op Admiral plan gate is HOLD — ' + ((d.review && d.review.reason) || 'manual review required before execution'));
|
if (res.status === 409 && d.review && d.review.status === 'hold') throw new Error('Op Admiral plan gate is HOLD — ' + ((d.review && d.review.reason) || 'manual review required before execution'));
|
||||||
if (!res.ok || d.error) throw new Error(d.error || ('launch failed (' + res.status + ')'));
|
if (!res.ok || d.error) throw new Error(d.error || ('launch failed (' + res.status + ')'));
|
||||||
|
|||||||
@@ -88,12 +88,40 @@ describe('local API authorization hardening invariants', () => {
|
|||||||
expect(parser).toMatch(/changes the effective network destination/);
|
expect(parser).toMatch(/changes the effective network destination/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Admiral live launch re-checks every General-produced execution target before mission bring-up', () => {
|
it('Admiral live launch permits expanded targets only with explicit operator approval receipts', () => {
|
||||||
const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS');
|
const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS');
|
||||||
|
|
||||||
expect(route).toMatch(/ensureExecTargetsWithinApprovedTarget\(execConfig\.targets, brief\.target\)/);
|
expect(route).toMatch(/ensureExecTargetsAuthorized\(execConfig\.targets,\s*brief\.target,\s*req\.body/);
|
||||||
expect(route).toMatch(/outOfScopeTargets\.length/);
|
expect(route).toMatch(/outOfScopeTargets\.length/);
|
||||||
expect(route.indexOf('ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target)'))
|
expect(route.indexOf('ensureExecTargetsAuthorized(execConfig.targets, brief.target'))
|
||||||
.toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)'));
|
.toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)'));
|
||||||
|
expect(route).toMatch(/approvals/);
|
||||||
|
expect(route).toMatch(/approvalIds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approval lookup requires explicit receipt ids and wildcard hosts require opt-in', () => {
|
||||||
|
const findApproval = routeBlock('function findApproval(', 'function createApprovalRequest');
|
||||||
|
const approvalMatches = sourceBlock('function approvalMatches(', 'function ensureExecTargetsWithinApprovedTarget');
|
||||||
|
|
||||||
|
expect(findApproval).toMatch(/body\.approvalId/);
|
||||||
|
expect(findApproval).toMatch(/body\.approvalIds/);
|
||||||
|
expect(findApproval).not.toMatch(/\[\.\.\.approvalRequests\.values\(\)\]/);
|
||||||
|
expect(serverSource).toMatch(/Wildcard host approval requires explicit opt-in/);
|
||||||
|
expect(serverSource).toMatch(/body\.allowWildcard !== true/);
|
||||||
|
expect(approvalMatches.indexOf("approval.target === '*'"))
|
||||||
|
.toBeLessThan(approvalMatches.indexOf('targetUsesWildcard(approval.target) && !approval.wildcardOptIn'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorize-target only approves existing pending receipts and cannot mint broad active-action approvals', () => {
|
||||||
|
const route = routeBlock("app.post('/api/approvals/authorize-target'", "app.post('/api/approvals/:id/reject'");
|
||||||
|
|
||||||
|
expect(route).toMatch(/approvalId/);
|
||||||
|
expect(route).toMatch(/approvalIds/);
|
||||||
|
expect(route).toMatch(/approvalRequests\.get\(id\)/);
|
||||||
|
expect(route).toMatch(/approval\.status !== 'pending'/);
|
||||||
|
expect(route).toMatch(/Math\.min\(30/);
|
||||||
|
expect(route).not.toMatch(/createApprovalRequest/);
|
||||||
|
expect(route).not.toMatch(/allowedActions/);
|
||||||
|
expect(route).not.toMatch(/command_execution['"],\s*['"]autonomous_execution/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+131
-7
@@ -607,6 +607,7 @@ interface ApprovalRequest {
|
|||||||
target: string;
|
target: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: ApprovalStatus;
|
status: ApprovalStatus;
|
||||||
|
wildcardOptIn?: boolean;
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
requestedBy: DraftSource | 'system';
|
requestedBy: DraftSource | 'system';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1191,10 +1192,29 @@ function approvalIsFresh(approval: ApprovalRequest): boolean {
|
|||||||
return Date.parse(approval.expiresAt) > Date.now();
|
return Date.parse(approval.expiresAt) > Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetUsesWildcard(target: string): boolean {
|
||||||
|
const raw = normalizeTargetValue(target).toLowerCase();
|
||||||
|
if (raw === '*') return true;
|
||||||
|
const withoutScheme = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
||||||
|
return withoutScheme.startsWith('*.') || hostFromTarget(raw).startsWith('*.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wildcardHostFromTarget(target: string): string {
|
||||||
|
const raw = normalizeTargetValue(target).toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
||||||
|
return raw.replace(/\/.*$/, '').replace(/:\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: string): boolean {
|
function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: string): boolean {
|
||||||
if (approval.action !== action) return false;
|
if (approval.action !== action) return false;
|
||||||
if (approval.target === '*') return action === 'model_call' || action === 'autonomous_execution';
|
if (approval.target === '*') return action === 'model_call' || action === 'autonomous_execution';
|
||||||
return hostFromTarget(approval.target) === hostFromTarget(target);
|
if (targetUsesWildcard(approval.target) && !approval.wildcardOptIn) return false;
|
||||||
|
const approvalHost = targetUsesWildcard(approval.target) ? wildcardHostFromTarget(approval.target) : hostFromTarget(approval.target);
|
||||||
|
const targetHost = hostFromTarget(target);
|
||||||
|
if (approvalHost.startsWith('*.')) {
|
||||||
|
const suffix = approvalHost.slice(1);
|
||||||
|
return targetHost.endsWith(suffix) && targetHost !== approvalHost.slice(2);
|
||||||
|
}
|
||||||
|
return approvalHost === targetHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] {
|
function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] {
|
||||||
@@ -1204,6 +1224,15 @@ function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget
|
|||||||
.filter(target => hostFromTarget(target) !== approvedHost);
|
.filter(target => hostFromTarget(target) !== approvedHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureExecTargetsAuthorized(
|
||||||
|
targets: string[],
|
||||||
|
approvedTarget: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): string[] {
|
||||||
|
return ensureExecTargetsWithinApprovedTarget(targets, approvedTarget)
|
||||||
|
.filter(target => !findApproval(body, 'autonomous_execution', target));
|
||||||
|
}
|
||||||
|
|
||||||
function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
|
function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
|
||||||
if (!approvalMatches(approval, action, target)) return false;
|
if (!approvalMatches(approval, action, target)) return false;
|
||||||
if (approval.operationId && operationId && approval.operationId !== operationId) return false;
|
if (approval.operationId && operationId && approval.operationId !== operationId) return false;
|
||||||
@@ -1211,10 +1240,17 @@ function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findApproval(body: Record<string, unknown>, action: GuardAction, target: string): ApprovalRequest | null {
|
function findApproval(body: Record<string, unknown>, action: GuardAction, target: string): ApprovalRequest | null {
|
||||||
const id = typeof body.approvalId === 'string' ? body.approvalId : '';
|
const ids = [
|
||||||
const approval = id ? approvalRequests.get(id) : undefined;
|
typeof body.approvalId === 'string' ? body.approvalId : '',
|
||||||
if (!approval || !approvalIsFresh(approval) || !approvalMatches(approval, action, target)) return null;
|
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
|
||||||
return approval;
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const approval = approvalRequests.get(id);
|
||||||
|
if (approval && approvalIsFresh(approval) && approvalMatches(approval, action, target)) return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApprovalRequest(action: GuardAction, target: string, reason: string, body: Record<string, unknown>): ApprovalRequest {
|
function createApprovalRequest(action: GuardAction, target: string, reason: string, body: Record<string, unknown>): ApprovalRequest {
|
||||||
@@ -1225,6 +1261,7 @@ function createApprovalRequest(action: GuardAction, target: string, reason: stri
|
|||||||
target,
|
target,
|
||||||
reason,
|
reason,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
wildcardOptIn: body.allowWildcard === true && targetUsesWildcard(target),
|
||||||
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
|
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
|
||||||
requestedBy: ['human', 'agent', 't3mp3st'].includes(String(body.source)) ? body.source as DraftSource : 'system',
|
requestedBy: ['human', 'agent', 't3mp3st'].includes(String(body.source)) ? body.source as DraftSource : 'system',
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
@@ -4905,6 +4942,15 @@ app.post('/api/approvals/request', (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Wildcard host approval requires explicit opt-in',
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reason = typeof body.reason === 'string' && body.reason.trim()
|
const reason = typeof body.reason === 'string' && body.reason.trim()
|
||||||
? body.reason.trim()
|
? body.reason.trim()
|
||||||
: `Approval requested for ${action} against ${target}`;
|
: `Approval requested for ${action} against ${target}`;
|
||||||
@@ -4928,6 +4974,68 @@ app.post('/api/approvals/:id/approve', (req: Request, res: Response) => {
|
|||||||
res.json(approval);
|
res.json(approval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/approvals/authorize-target', (req: Request, res: Response) => {
|
||||||
|
const body = req.body as Record<string, unknown>;
|
||||||
|
const target = normalizeTargetValue(body.target);
|
||||||
|
const approvalIds = [
|
||||||
|
typeof body.approvalId === 'string' ? body.approvalId : '',
|
||||||
|
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!approvalIds.length) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'approvalId or approvalIds required',
|
||||||
|
next: 'Request a pending receipt first, then approve that exact receipt id.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target === '*') {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Wildcard target authorization is not allowed for active actions',
|
||||||
|
next: 'Authorize one concrete host or URL so receipts remain target-scoped.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Wildcard host authorization requires explicit opt-in',
|
||||||
|
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMinutes = Math.max(1, Math.min(30, Number(body.ttlMinutes || 30)));
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
|
||||||
|
const approvals: ApprovalRequest[] = [];
|
||||||
|
for (const id of [...new Set(approvalIds)]) {
|
||||||
|
const approval = approvalRequests.get(id);
|
||||||
|
if (!approval) {
|
||||||
|
res.status(404).json({ error: 'Approval request not found', approvalId: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (approval.status !== 'pending') {
|
||||||
|
res.status(409).json({ error: 'Approval request is not pending', approvalId: id, status: approval.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target && !approvalMatches(approval, approval.action, target)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Approval target mismatch',
|
||||||
|
approvalId: id,
|
||||||
|
approvalTarget: approval.target,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
approval.status = 'approved';
|
||||||
|
approval.approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'local-operator';
|
||||||
|
approval.expiresAt = expiresAt;
|
||||||
|
approval.updatedAt = nowIso();
|
||||||
|
emitContractEvent('approval.approved', { approvalId: approval.id, action: approval.action, target: approval.target });
|
||||||
|
approvals.push(approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ target: target || null, expiresAt, approvals });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/approvals/:id/reject', (req: Request, res: Response) => {
|
app.post('/api/approvals/:id/reject', (req: Request, res: Response) => {
|
||||||
const approval = approvalRequests.get(req.params.id);
|
const approval = approvalRequests.get(req.params.id);
|
||||||
if (!approval) {
|
if (!approval) {
|
||||||
@@ -6106,6 +6214,14 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
|||||||
if (!guard.allowed) { blockForApproval(res, guard); return; }
|
if (!guard.allowed) { blockForApproval(res, guard); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'local-agent') {
|
||||||
|
const localAgent = await requireLiveLocalAgent(model);
|
||||||
|
if (!localAgent.ok) {
|
||||||
|
res.status(503).json({ error: localAgent.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE
|
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE
|
||||||
// (canonicalize + confine to the allowed root) before we build/start the command,
|
// (canonicalize + confine to the allowed root) before we build/start the command,
|
||||||
// so a bad/escaping path 400s cleanly instead of half-starting a run.
|
// so a bad/escaping path 400s cleanly instead of half-starting a run.
|
||||||
@@ -7373,12 +7489,20 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
|
|||||||
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
|
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const outOfScopeTargets = ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target);
|
const outOfScopeTargets = ensureExecTargetsAuthorized(execConfig.targets, brief.target, req.body as Record<string, unknown>);
|
||||||
if (outOfScopeTargets.length) {
|
if (outOfScopeTargets.length) {
|
||||||
|
const approvals = outOfScopeTargets.map(target => createApprovalRequest(
|
||||||
|
'autonomous_execution',
|
||||||
|
target,
|
||||||
|
`Admiral plan expanded execution scope from ${brief.target} to ${target}`,
|
||||||
|
req.body as Record<string, unknown>
|
||||||
|
));
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'Admiral LIVE plan contains targets outside the approved brief target',
|
error: 'Admiral LIVE plan needs approval for expanded target scope',
|
||||||
approvedTarget: brief.target,
|
approvedTarget: brief.target,
|
||||||
outOfScopeTargets,
|
outOfScopeTargets,
|
||||||
|
approvals,
|
||||||
|
next: 'Approve the expanded target receipts, then retry /api/admiral/launch with approvalIds.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user