Skip to content

Commit dbc8eb9

Browse files
bogdanpricopclaude
andcommitted
feat(copilot): actionable recommendations — one-click safe fixes
Copilot recommendations now carry the finding's SAFE remediation.action through context → brief → UI, and the briefing shows an "Apply fix" button (admin) that calls the same guarded posture.remediate path (e.g. fw-reconcile through the lockout guard), then re-briefs. Risky fixes stay guided (link only); copilot remains advise-only. 1 test. Full suite: 1825 passing across 116 suites. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 744c13e commit dbc8eb9

7 files changed

Lines changed: 47 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-dash",
3-
"version": "8.9.43-alpha.1",
3+
"version": "8.9.44-alpha.1",
44
"description": "Full-featured Docker management dashboard",
55
"main": "src/server.js",
66
"scripts": {

public/js/pages/copilot.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,15 @@ const CopilotPage = {
5959
<div class="card-body" style="padding:0">
6060
${(b.recommendations || []).length === 0 ? '<div class="empty-msg"><i class="fas fa-circle-check" style="color:var(--green)"></i> Nothing pressing — your estate looks clean.</div>'
6161
: `<table class="data-table"><thead><tr><th style="width:80px">Sev</th><th>What</th><th>Why</th><th style="width:150px"></th></tr></thead>
62-
<tbody>${b.recommendations.map(r => `
62+
<tbody>${b.recommendations.map((r, i) => `
6363
<tr>
6464
<td><span class="badge" style="background:${this._SEV[r.severity] || '#8b949e'};color:#fff">${Utils.escapeHtml(r.severity)}</span></td>
6565
<td><strong>${Utils.escapeHtml(r.title)}</strong>${r.host ? `<div class="text-sm text-dim">${Utils.escapeHtml(r.host)}</div>` : ''}</td>
6666
<td class="text-sm text-dim">${Utils.escapeHtml(r.why || '')}</td>
67-
<td style="text-align:right">${r.link ? `<a class="btn btn-xs btn-secondary" href="${Utils.escapeHtml(r.link)}"><i class="fas fa-arrow-right"></i> ${Utils.escapeHtml(r.action || 'Open')}</a>` : ''}</td>
67+
<td style="text-align:right;white-space:nowrap">
68+
${(r.fixAction && this._isAdmin) ? `<button class="btn btn-xs btn-primary" data-cp-fix="${i}"><i class="fas fa-wand-magic-sparkles"></i> Apply fix</button> ` : ''}
69+
${r.link ? `<a class="btn btn-xs btn-secondary" href="${Utils.escapeHtml(r.link)}"><i class="fas fa-arrow-right"></i> ${Utils.escapeHtml(r.action || 'Open')}</a>` : ''}
70+
</td>
6871
</tr>`).join('')}</tbody></table>`}
6972
</div>
7073
</div>
@@ -85,6 +88,21 @@ const CopilotPage = {
8588
this._renderTranscript();
8689
el.querySelector('#cp-send')?.addEventListener('click', () => this._ask());
8790
el.querySelector('#cp-q')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') this._ask(); });
91+
el.querySelectorAll('[data-cp-fix]').forEach(btn => btn.addEventListener('click', () => this._applyFix(parseInt(btn.getAttribute('data-cp-fix'), 10), btn)));
92+
},
93+
94+
async _applyFix(idx, btn) {
95+
const rec = (this._brief.recommendations || [])[idx];
96+
if (!rec || !rec.fixAction) return;
97+
const ok = await Modal.confirm(`Apply the safe fix for “${rec.title}”? (Goes through the firewall lockout guard.)`, { confirmText: 'Apply fix' });
98+
if (!ok) return;
99+
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
100+
try {
101+
const r = await Api.remediatePosture(rec.fixAction);
102+
if (r && r.ok === false) { Toast.error(r.error || 'Fix failed'); }
103+
else { Toast.success('Fix applied'); }
104+
await this._load(true);
105+
} catch (err) { Toast.error(err.message); if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Apply fix'; } }
88106
},
89107

90108
_renderTranscript() {

public/js/pages/whatsnew.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ const WhatsNewPage = {
99
// Add new releases at the TOP of this array.
1010
// Types: feature, fix, improvement, security, breaking
1111
_releases: [
12+
{
13+
version: '8.9.44-alpha.1',
14+
date: '2026-07-13',
15+
title: 'Copilot — actionable recommendations (one-click safe fixes)',
16+
changes: [
17+
{ type: 'feature', text: 'Copilot recommendations that map to a SAFE remediation now offer an “Apply fix” button right in the briefing (admin) — it reuses the exact same guarded path as Posture (e.g. re-apply drifted firewall rules through the lockout guard), then re-briefs. Risky fixes still stay guided (deep-link only). The copilot stays advise-only; the one-click is limited to actions that create no new exposure. Ties Copilot → Posture remediation into one flow.' },
18+
],
19+
},
1220
{
1321
version: '8.9.43-alpha.1',
1422
date: '2026-07-13',

src/__tests__/copilot.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ describe('copilot rule-based briefing', () => {
1919
// blueprint drift becomes a recommendation
2020
expect(recs.some(r => /drifted/.test(r.title))).toBe(true);
2121
});
22+
23+
test('carries a safe one-click fixAction through from the finding', () => {
24+
const recs = brief.recommend({ findings: [
25+
{ severity: 'medium', title: 'drifted', detail: 'd', remediation: { label: 'Re-apply', link: '#/firewall', action: { type: 'fw-reconcile', hostId: 2 } } },
26+
{ severity: 'high', title: 'no fix', detail: 'x', remediation: { label: 'Open', link: '#/x' } },
27+
] });
28+
const drift = recs.find(r => r.title === 'drifted');
29+
expect(drift.fixAction).toEqual({ type: 'fw-reconcile', hostId: 2 });
30+
expect(recs.find(r => r.title === 'no fix').fixAction).toBeNull();
31+
});
2232
test('summaryLine reflects counts', () => {
2333
expect(brief.summaryLine({ grade: 'C', score: 60, counts: { critical: 1, high: 0, medium: 2 }, hosts: [{}, {}] }))
2434
.toMatch(/grade C \(60\/100\).*1 critical.*2 medium.*2 host/);

src/services/copilot/brief.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ function recommend(ctx) {
1313
severity: f.severity, title: f.title, why: f.detail || '',
1414
action: (f.remediation && f.remediation.label) || 'Review',
1515
link: (f.remediation && f.remediation.link) || null,
16+
// Carry a SAFE one-click action (e.g. re-apply firewall drift) so the
17+
// Copilot can offer "Apply fix" straight from the recommendation.
18+
fixAction: (f.remediation && f.remediation.action) || null,
1619
host: f.host || null,
1720
});
1821
}

src/services/copilot/context.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ async function assemble() {
2020
out.score = scan.global.score; out.grade = scan.global.grade; out.counts = scan.global.counts;
2121
const hostName = new Map((scan.hosts || []).map(h => [h.hostId, h.name]));
2222
out.findings = (scan.findings || []).filter(f => !f.muted).slice(0, 12).map(f => ({
23-
severity: f.severity, checkId: f.checkId, title: f.title,
23+
severity: f.severity, checkId: f.checkId, title: f.title, key: f.key,
2424
host: f.hostId != null ? (hostName.get(f.hostId) || `host ${f.hostId}`) : null,
2525
detail: f.detail,
26-
remediation: f.remediation ? { label: f.remediation.label, link: f.remediation.link } : null,
26+
// Keep the SAFE one-click action (if any) for the UI path — the LLM path
27+
// trims findings down separately and never sees this.
28+
remediation: f.remediation ? { label: f.remediation.label, link: f.remediation.link, action: f.remediation.action || null } : null,
2729
}));
2830
out.hostScores = (scan.hosts || []).map(h => ({ name: h.name, grade: h.grade, score: h.score }));
2931
} catch (e) { log.debug('posture context failed', { error: e.message }); }

src/version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
// Single source of truth for the application version.
33
// Updated automatically by: npm version X.Y.Z (via scripts/sync-version.js)
44
// server.js reads this to inject into index.html at startup — no build step needed.
5-
module.exports = '8.9.43-alpha.1';
5+
module.exports = '8.9.44-alpha.1';

0 commit comments

Comments
 (0)