// Screen flow: prepare model -> approval -> live progress -> findings -> results + save popup (60 s auto-save). import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from './auditor-core.js'; import { buildReportHtml } from './report.js'; const $ = id => document.getElementById(id); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const SAVE_TIMEOUT_SECONDS = 60; let plan = null, lib = null, fixes = {}, model = null, lastReport = null, countdownTimer = null, lastEvidence = null, pendingFix = null; function show(name) { for (const screen of document.querySelectorAll('.screen')) screen.classList.toggle('hidden', screen.id !== `screen-${name}`); } function setBar(id, fraction) { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; } function stopped(title, text) { $('stopped-title').textContent = title; $('stopped-text').textContent = text; show('stopped'); } // 1. Prepare the auditor model (downloaded once, then cached by WebLLM) async function prepare() { show('model'); plan = await window.devseis.plan(); const libraryData = await window.devseis.library(); lib = new Library(libraryData); fixes = libraryData.fixes || {}; $('device').textContent = `${plan.hostname} · ${plan.platform}`; if (!model && !new URLSearchParams(location.search).has('smoke')) { try { const { loadModel } = await import('./model.js'); $('model-name').textContent = 'Devseis auditor model'; $('model-status').textContent = 'Downloading the auditor model (first run only)…'; const version = await window.devseis.modelVersion(); model = await loadModel(({ progress, text }) => { setBar('model-bar', progress); $('model-percent').textContent = `${Math.round(progress * 100)}%`; $('model-status').textContent = text; }, version); $('model-name').textContent = model.name + (model.fineTuned ? '' : ' (base model)'); $('model-status').textContent = 'The auditor is ready and runs offline on this computer.'; setBar('model-bar', 1); $('model-percent').textContent = '100%'; await sleep(800); } catch (error) { model = null; $('model-name').textContent = 'Built-in report writer'; $('model-percent').textContent = ''; $('model-status').textContent = `${error.message} The audit will use the built-in report writer instead.`; await sleep(2500); } } showApproval(); } // 2. Approval: show everything that will be checked (row 1: baseline + GDPR, row 2: AI Act + organisation) const SECTION_INFO = { baseline: { icon: '🛡️', title: 'Security baseline', sub: 'Passwords, accounts, antivirus, firewall, screen lock, USB, encryption, updates' }, gdpr: { icon: '🔐', title: 'GDPR technical controls', sub: 'Updates, logging, time, backup, guest and remote access, diagnostics' }, aiact: { icon: '🤖', title: 'EU AI Act', sub: 'AI tools on this computer and how they are governed' }, org: { icon: '🏢', title: 'Organisation', sub: 'Company records and policies, answered in organisational.conf' }, }; function showApproval() { $('plan').innerHTML = ''; const total = plan.sections.reduce((n, s) => n + s.checks.length, 0); $('plan-total').textContent = `${total} checks in ${plan.sections.length} groups`; for (const section of plan.sections) { const info = SECTION_INFO[section.id] || { icon: '•', title: section.title, sub: '' }; const box = document.createElement('div'); box.className = `section section-${section.id}`; box.innerHTML = `

`; box.querySelector('.section-icon').textContent = info.icon; box.querySelector('h3').textContent = info.title; box.querySelector('p').textContent = info.sub; box.querySelector('.count').textContent = `${section.checks.length} checks`; const list = box.querySelector('ul'); for (const check of section.checks) { const item = document.createElement('li'); item.textContent = check.name; list.appendChild(item); } $('plan').appendChild(box); } show('approve'); } // 3. Run the audit and show live progress. fix = { action: 'apply'|'undo', ids }: fix first, then audit again. async function runAudit(fix = null) { const elevated = fix ? true : $('elevated').checked; show('progress'); $('progress-title').textContent = fix ? (fix.action === 'undo' ? 'Undoing fixes and checking again' : 'Fixing and checking again') : 'Auditing this computer'; $('progress-step').textContent = 'Collecting evidence'; $('progress-current').textContent = elevated ? 'Waiting for administrator approval…' : 'Starting…'; setBar('progress-bar', 0); const rows = new Map(); const order = []; $('checklist').innerHTML = ''; for (const section of plan.sections) { for (const check of section.checks) { const row = document.createElement('div'); row.className = 'check'; row.innerHTML = ''; row.querySelector('.name').textContent = check.name; row.querySelector('.group').textContent = section.title.replace(' CONTROLS', '').replace('COMPLIANCE RESULTS', 'BASELINE'); $('checklist').appendChild(row); rows.set(check.id, row); order.push(check.id); } } const total = order.length; let finished = 0; $('progress-count').textContent = `0 / ${total}`; const markRunning = () => { const next = order.find(id => !rows.get(id).classList.contains('done')); if (next) rows.get(next).classList.add('running'); }; const markDone = (id, status) => { const row = rows.get(id); if (!row || row.classList.contains('done')) return; row.classList.remove('running'); row.classList.add('done', `s-${status}`); row.querySelector('.group').textContent = status; finished += 1; $('progress-count').textContent = `${finished} / ${total}`; setBar('progress-bar', finished / total); }; const unsubscribe = window.devseis.onProgress(item => { markDone(item.id, item.status); $('progress-current').textContent = `Checked: ${item.name}`; markRunning(); }); let result; try { result = await window.devseis.runAudit(elevated, fix); } catch (error) { unsubscribe(); const cancelled = /cancelled/i.test(error.message); stopped(cancelled ? (fix ? 'Fix cancelled' : 'Audit cancelled') : 'The audit stopped', cancelled ? 'Administrator approval was not given. Nothing was checked and nothing was changed.' : error.message); return; } unsubscribe(); const evidence = result.evidence; for (const r of evidence.results) markDone(r.id, r.status); // 4. Write the findings (AI model when available, otherwise the built-in writer) const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status)); const ai = aiPrompt(evidence, lib); const steps = targets.length + 1 + (ai ? 1 : 0); let written = 0; const sources = { model: 0, template: 0 }; const generate = model ? model.generate : null; const tick = label => { written += 1; $('progress-count').textContent = `${written} / ${steps}`; setBar('progress-bar', written / steps); $('progress-current').textContent = label; }; $('progress-title').textContent = 'Writing the audit report'; $('progress-step').textContent = model ? 'AI auditor is writing findings' : 'Writing findings'; $('progress-count').textContent = `0 / ${steps}`; setBar('progress-bar', 0); const findings = {}, findingSource = {}; for (const r of targets) { $('progress-current').textContent = `Writing: ${r.name}`; const { answer, source } = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate); findings[r.id] = answer; findingSource[r.id] = source; sources[source] += 1; tick(`Written: ${r.name}`); } // Score, counts and top gaps always come from the evidence; the model may only phrase the text. const summaryBase = summaryTemplate(evidence, lib); const summaryOut = await writeAnswer('summary', summaryPrompt(evidence, lib), summaryBase, generate); const summary = summaryOut.source === 'model' ? { ...summaryBase, summary: summaryOut.answer.summary, next_steps: summaryOut.answer.next_steps } : summaryBase; sources[summaryOut.source] += 1; tick('Written: summary'); let aiResult = null; if (ai) { const out = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate); aiResult = out.answer; sources[out.source] += 1; tick('Written: AI tool classification'); } const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256: result.sha256, model: model ? model.name : null, sources }); lastReport = { html, evidenceText: result.evidenceText, hostname: evidence.host.hostname }; lastEvidence = evidence; showResults(evidence, summary, sources); renderAllResults(evidence, findings, findingSource); await renderFixes(evidence, result.fixResults); openSaveModal(); } // 6. Fixes: one-click fixes (tested scripts, undo saved) and settings pages for the rest. The AI never runs commands. const OUTCOME = { applied: '✅', undone: '↩️', unchanged: '✔️', skipped: '⚠️', failed: '❌' }; async function renderFixes(evidence, fixResults) { const os = plan.os; const name = id => (evidence.results.find(r => r.id === id) || {}).name || id; if (fixResults && fixResults.length) { $('fix-banner').innerHTML = `${fixResults.some(r => r.outcome === 'undone') ? 'Undo finished' : 'Fixes finished'}

The results below come from checking this computer again after the change.

`; $('fix-banner').classList.remove('hidden'); } else $('fix-banner').classList.add('hidden'); // A fix can depend on software being present (e.g. ClamAV updates only where ClamAV runs): requires.check's result must contain requires.contains. const usable = f => !f.requires || ((evidence.results.find(x => x.id === f.requires.check) || {}).actual || '').toLowerCase().includes(f.requires.contains.toLowerCase()); const gaps = evidence.results.filter(r => r.status === 'Non-Compliant' && fixes[r.id] && fixes[r.id][os] && usable(fixes[r.id][os])); const auto = gaps.filter(r => fixes[r.id][os].kind === 'auto'); // Several checks can share one fix (lockout threshold, duration and observation): show it once, fixing all of them. const autoGroups = []; for (const r of auto) { const f = fixes[r.id][os], g = autoGroups.find(x => x.f.title === f.title && x.f.changes === f.changes); if (g) g.ids.push(r.id); else autoGroups.push({ f, ids: [r.id] }); } // Several checks can share one settings page and the same steps (screen lock): show them once. const seen = new Set(); const guided = gaps.filter(r => fixes[r.id][os].kind === 'settings') .filter(r => { const f = fixes[r.id][os], key = f.url + f.steps.join('|'); if (seen.has(key)) return false; seen.add(key); return true; }); const history = await window.devseis.fixHistory().catch(() => []); const last = [...history].reverse().find(h => h.action === 'apply'); const undoable = last ? last.results.filter(r => r.outcome === 'applied' && fixes[r.id]?.[os]?.undo).map(r => r.id) : []; if (!auto.length && !guided.length && !undoable.length) { $('fixes').classList.add('hidden'); return; } $('fixes').innerHTML = `

🛠 Fix problems

${auto.length ? `

Arx can fix these for you. Most changes can be undone.

${autoGroups.map(({ f, ids }) => ``).join('')}
` : ''} ${guided.length ? `

${os === 'macos' ? 'Fix in System Settings' : 'Fix in Settings'}

These need your password, a recovery key or a restart, so you make the change. Arx opens the right page.

${guided.map(r => { const f = fixes[r.id][os]; return `
${escapeHtml(f.title)}
    ${f.steps.map(x => `
  1. ${escapeHtml(x)}
  2. `).join('')}
${f.url ? `` : ''}
`; }).join('')}

When you are done, choose Run another audit to check again.

` : ''} ${undoable.length ? `
Last fixes (${new Date(last.at).toLocaleString()}): ${[...new Set(undoable.map(id => fixes[id][os].title))].map(escapeHtml).join(', ')}
` : ''}`; $('fixes').classList.remove('hidden'); const selected = () => [...$('fixes').querySelectorAll('input[data-fix]:checked')].flatMap(i => i.dataset.fix.split(',')); const updateCount = () => { const b = $('fix-selected'); if (b) { const n = $('fixes').querySelectorAll('input[data-fix]:checked').length; b.textContent = `Fix selected (${n})`; b.disabled = !n; } }; $('fixes').querySelectorAll('input[data-fix]').forEach(i => i.addEventListener('change', updateCount)); $('fixes').querySelectorAll('[data-settings]').forEach(b => b.addEventListener('click', () => window.devseis.openFixSettings(b.dataset.settings))); if ($('fix-selected')) $('fix-selected').addEventListener('click', () => confirmFix('apply', selected())); if ($('fix-undo')) $('fix-undo').addEventListener('click', () => confirmFix('undo', undoable)); } function confirmFix(action, ids) { if (!ids.length) return; const os = plan.os; pendingFix = { action, ids }; const shown = [...new Map(ids.map(id => [fixes[id][os].title, fixes[id][os]])).values()]; // shared fixes listed once $('fix-title').textContent = action === 'undo' ? 'Undo these fixes?' : `Fix ${shown.length} problem${shown.length > 1 ? 's' : ''}?`; $('fix-intro').textContent = action === 'undo' ? 'Arx will put these settings back as they were before, then check this computer again.' : 'Arx will change these settings, then check this computer again. You can undo most fixes afterwards.'; $('fix-confirm-list').innerHTML = shown.map(f => { return `
  • ${escapeHtml(f.title)}
    ${escapeHtml(action === 'undo' ? 'Back to the previous setting.' : f.changes)}
  • `; }).join(''); $('fix-password-note').textContent = { macos: 'macOS will ask for your administrator password.', linux: 'Your system will ask for your password (administrator rights).', windows: 'Windows will ask you to allow the change (User Account Control).' }[os] || 'Your computer will ask for your administrator password.'; $('fix-modal').classList.remove('hidden'); } $('fix-cancel').addEventListener('click', () => { pendingFix = null; $('fix-modal').classList.add('hidden'); }); $('fix-go').addEventListener('click', () => { const fix = pendingFix; pendingFix = null; $('fix-modal').classList.add('hidden'); clearInterval(countdownTimer); $('save-modal').classList.add('hidden'); if (fix) runAudit(fix); }); // 5. Results and saving function showResults(evidence, summary, sources) { const pills = Object.entries(evidence.summary).map(([k, v]) => ``).join(''); const gaps = summary.top_gaps.map(g => `
  • ${g.risk} — ${escapeHtml(g.name)}: ${escapeHtml(g.found)}
  • `).join(''); $('result-summary').innerHTML = `
    ${summary.score.percent}%
    ${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant
    ${pills}

    ${escapeHtml(summary.summary)}

    ${gaps ? `Top gaps` : ''}

    Findings: ${sources.model} written by the AI auditor, ${sources.template} by the built-in writer.

    `; $('saved-note').textContent = ''; $('result-summary').querySelectorAll('[data-filter]').forEach(b => b.addEventListener('click', () => { setResultFilter(b.dataset.filter); $('all-results').scrollIntoView({ behavior: 'smooth', block: 'start' }); })); show('done'); } // 7. All results: every check with its status, and on click the reasons behind it. const STATUS_LABEL = { Compliant: 'Compliant', 'Non-Compliant': 'Non-Compliant', Pending: 'Pending', Error: 'Error', NotApplicable: 'Not applicable' }; const STATUS_ORDER = ['Non-Compliant', 'Error', 'Pending', 'Compliant', 'NotApplicable']; const RISK_RANK = { High: 0, Medium: 1, Low: 2 }; let resultFilter = 'all'; function whyText(r) { const found = r.actual || 'nothing reported', need = r.expected || 'the baseline value'; if (r.status === 'Compliant') return 'What the audit found meets the requirement, so this check passes.'; if (r.status === 'Non-Compliant') return 'What the audit found does not meet the requirement, so this check fails.'; if (r.status === 'Pending') return `Not decided yet: ${r.detail || 'it needs a decision or an answer from you or your organisation'}. Current state: ${found}.`; if (r.status === 'Error') return `This setting could not be read${r.detail ? `: ${r.detail}` : ''}. Running the audit with administrator rights usually fixes this.`; return `This check does not apply here: ${r.detail || found}.`; } function resultDetails(evidence, r, finding, source) { let f = finding; if (!f) { try { f = findingTemplate(evidence, r, lib); } catch { f = null; } } const guide = lib.guide(r.id) || {}; const fix = (fixes[r.id] || {})[plan.os]; const parts = [`

    Why it is ${escapeHtml((STATUS_LABEL[r.status] || r.status).toLowerCase())}

    ${escapeHtml(whyText(r))}

    Found${escapeHtml(r.actual || '—')}Required${escapeHtml(r.expected || '—')}${r.detail && r.status !== 'Error' && r.status !== 'NotApplicable' && r.status !== 'Pending' ? `Note${escapeHtml(r.detail)}` : ''}
    `]; if (f && f.finding && r.status !== 'Compliant') parts.push(`

    Finding${source === 'model' ? ' written by the AI auditor' : ''}

    ${escapeHtml(f.finding)}

    `); if (guide.why && !(f && f.finding && r.status !== 'Compliant' && f.finding.includes(guide.why))) parts.push(`

    Why it matters

    ${escapeHtml(guide.why)}

    `); if (f && f.risk && f.risk.level && !['None', 'Unknown'].includes(f.risk.level)) parts.push(`

    Risk

    ${escapeHtml(f.risk.level)} ${escapeHtml(f.risk.reason || '')}

    `); if (f && f.recommendation && f.recommendation.length && r.status !== 'Compliant') parts.push(`

    How to fix

      ${f.recommendation.map(x => `
    1. ${escapeHtml(x)}
    2. `).join('')}
    ${fix && r.status === 'Non-Compliant' ? `

    🛠 Arx can help: ${escapeHtml(fix.title)} in Fix problems above.

    ` : ''}
    `); if (f && f.verification && r.status !== 'Compliant') parts.push(`

    Check it worked

    ${escapeHtml(f.verification)}

    `); const refs = (f && f.references && f.references.length) ? f.references : lib.references(r.id).map(([label]) => label); if (refs.length) parts.push(`
    ${refs.map(x => `${escapeHtml(x)}`).join('')}
    `); parts.push(`

    ${r.evidence === 'self-reported' ? 'Self-reported by the user, not verified on this computer.' : 'Read from this computer by the audit.'} Check id: ${escapeHtml(r.id)}

    `); return parts.join(''); } function setResultFilter(filter) { resultFilter = filter; $('all-results').querySelectorAll('[data-rf]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.rf === filter))); $('all-results').querySelectorAll('.res').forEach(d => d.classList.toggle('hidden', filter !== 'all' && d.dataset.status !== filter)); } function renderAllResults(evidence, findings, findingSource) { const risk = r => RISK_RANK[(lib.guide(r.id) || {}).risk?.level] ?? 3; const order = new Map(evidence.results.map((r, i) => [r.id, i])); const rows = [...evidence.results].sort((a, b) => (STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)) || (risk(a) - risk(b)) || (order.get(a.id) - order.get(b.id))); const counts = Object.fromEntries(STATUS_ORDER.map(s => [s, rows.filter(r => r.status === s).length])); $('all-results').innerHTML = `

    All results

    Every check of this audit. Click a check to see why it has this status and what to do.

    ${STATUS_ORDER.filter(s => counts[s]).map(s => ``).join('')}
    ${rows.map(r => `
    ${escapeHtml(STATUS_LABEL[r.status] || r.status)}${escapeHtml(r.name)}${escapeHtml(r.actual || '')}
    `).join('')}
    `; // Details are built when a check is opened (fast with 60+ checks). $('all-results').querySelectorAll('details.res').forEach(d => d.addEventListener('toggle', () => { const body = d.querySelector('.res-body'); if (d.open && !body.innerHTML) { const r = evidence.results.find(x => x.id === d.dataset.id); body.innerHTML = resultDetails(evidence, r, findings[r.id], findingSource[r.id]); } })); $('all-results').querySelectorAll('[data-rf]').forEach(b => b.addEventListener('click', () => setResultFilter(b.dataset.rf))); setResultFilter('all'); $('all-results').classList.remove('hidden'); } function escapeHtml(value) { const d = document.createElement('div'); d.textContent = String(value ?? ''); return d.innerHTML; } function openSaveModal() { let remaining = SAVE_TIMEOUT_SECONDS; $('countdown').textContent = remaining; setBar('countdown-bar', 1); $('save-modal').classList.remove('hidden'); clearInterval(countdownTimer); countdownTimer = setInterval(() => { remaining -= 1; $('countdown').textContent = remaining; setBar('countdown-bar', remaining / SAVE_TIMEOUT_SECONDS); if (remaining <= 0) save('auto', true); }, 1000); } async function save(mode, automatic = false) { clearInterval(countdownTimer); $('save-modal').classList.add('hidden'); if (!lastReport) return; $('saved-note').textContent = 'Saving…'; try { const result = await window.devseis.saveReport({ ...lastReport, mode }); if (!result.saved) { $('saved-note').textContent = 'The report was not saved. Use "Save report as PDF" to save it.'; return; } $('saved-note').innerHTML = ''; const prefix = document.createTextNode(automatic ? 'No answer after 60 seconds — saved automatically to ' : 'Saved to '); const link = document.createElement('a'); link.href = '#'; link.textContent = result.pdfPath; link.addEventListener('click', event => { event.preventDefault(); window.devseis.showInFolder(result.pdfPath); }); $('saved-note').append(prefix, link); } catch (error) { $('saved-note').textContent = `Saving failed: ${error.message}`; } } $('run').addEventListener('click', () => runAudit()); $('cancel').addEventListener('click', () => stopped('Audit cancelled', 'Nothing was checked and nothing was changed.')); $('restart').addEventListener('click', showApproval); $('again').addEventListener('click', showApproval); $('save-again').addEventListener('click', () => save('choose')); $('ask-chat').addEventListener('click', () => { if (lastReport) window.devseis.openChat(lastReport.evidenceText); }); $('save-docs').addEventListener('click', () => save('auto')); $('save-choose').addEventListener('click', () => save('choose')); prepare();