clawarmor 3.1.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ // ClawArmor baseline command handler.
2
+ // Usage:
3
+ // clawarmor baseline save [--name <label>]
4
+ // clawarmor baseline list
5
+ // clawarmor baseline diff [--from <label>] [--to <label>]
6
+
7
+ import { paint } from './output/colors.js';
8
+ import { saveBaseline, listBaselines, diffBaselines, loadBaseline } from './baseline.js';
9
+ import { runAuditQuiet } from './audit-quiet.js';
10
+
11
+ const SEP = paint.dim('─'.repeat(52));
12
+
13
+ function box(title) {
14
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
15
+ return [
16
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
17
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
18
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
19
+ ].join('\n');
20
+ }
21
+
22
+ function fmtDate(iso) {
23
+ if (!iso) return 'unknown';
24
+ try { return new Date(iso).toLocaleString(); } catch { return iso; }
25
+ }
26
+
27
+ function todayLabel() {
28
+ const d = new Date();
29
+ return `baseline-${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
30
+ }
31
+
32
+ /**
33
+ * Main baseline command router.
34
+ * @param {string[]} args - args after "baseline"
35
+ */
36
+ export async function runBaseline(args) {
37
+ const sub = args[0];
38
+
39
+ if (!sub || sub === '--help' || sub === 'help') {
40
+ console.log('');
41
+ console.log(` ${paint.bold('clawarmor baseline')} — save and compare security baselines`);
42
+ console.log('');
43
+ console.log(` ${paint.cyan('Subcommands:')}`);
44
+ console.log(` ${paint.cyan('save')} [--name <label>] Save current audit as a baseline`);
45
+ console.log(` ${paint.cyan('list')} List all saved baselines`);
46
+ console.log(` ${paint.cyan('diff')} [--from <label>] [--to <label>] Diff two baselines`);
47
+ console.log('');
48
+ return 0;
49
+ }
50
+
51
+ // ── SAVE ────────────────────────────────────────────────────────────────────
52
+ if (sub === 'save') {
53
+ const nameIdx = args.indexOf('--name');
54
+ const label = nameIdx !== -1 && args[nameIdx + 1] ? args[nameIdx + 1] : todayLabel();
55
+
56
+ console.log(''); console.log(box('ClawArmor Baseline Save')); console.log('');
57
+ console.log(` ${paint.dim('Running audit to capture current security posture...')}`);
58
+ console.log('');
59
+
60
+ let auditResult;
61
+ try {
62
+ auditResult = await runAuditQuiet({});
63
+ } catch (e) {
64
+ console.log(` ${paint.red('✗')} Audit failed: ${e.message}`);
65
+ console.log('');
66
+ return 1;
67
+ }
68
+
69
+ const filePath = saveBaseline({
70
+ label,
71
+ score: auditResult.score,
72
+ findings: auditResult.findings,
73
+ profile: auditResult.profile || null,
74
+ });
75
+
76
+ console.log(` ${paint.green('✓')} Baseline saved`);
77
+ console.log(` ${paint.dim('Label:')} ${paint.bold(label)}`);
78
+ console.log(` ${paint.dim('Score:')} ${paint.bold(String(auditResult.score))}/100`);
79
+ console.log(` ${paint.dim('Findings:')} ${auditResult.findings.length}`);
80
+ console.log(` ${paint.dim('File:')} ${filePath}`);
81
+ console.log('');
82
+ return 0;
83
+ }
84
+
85
+ // ── LIST ─────────────────────────────────────────────────────────────────────
86
+ if (sub === 'list') {
87
+ console.log(''); console.log(box('ClawArmor Baselines')); console.log('');
88
+ const baselines = listBaselines();
89
+ if (!baselines.length) {
90
+ console.log(` ${paint.dim('No baselines saved yet.')}`);
91
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor baseline save')} ${paint.dim('to create one.')}`);
92
+ console.log('');
93
+ return 0;
94
+ }
95
+ console.log(` ${paint.bold(String(baselines.length))} baseline${baselines.length !== 1 ? 's' : ''} saved:\n`);
96
+ for (const b of baselines) {
97
+ const scoreStr = b.score != null ? `${b.score}/100` : 'n/a';
98
+ const profileStr = b.profile ? ` ${paint.dim('[' + b.profile + ']')}` : '';
99
+ console.log(` ${paint.cyan(b.label)}${profileStr}`);
100
+ console.log(` ${paint.dim('Date:')} ${fmtDate(b.savedAt)}`);
101
+ console.log(` ${paint.dim('Score:')} ${scoreStr}`);
102
+ console.log('');
103
+ }
104
+ console.log(` ${paint.dim('To compare:')} ${paint.cyan('clawarmor baseline diff --from <label> --to <label>')}`);
105
+ console.log('');
106
+ return 0;
107
+ }
108
+
109
+ // ── DIFF ──────────────────────────────────────────────────────────────────────
110
+ if (sub === 'diff') {
111
+ const fromIdx = args.indexOf('--from');
112
+ const toIdx = args.indexOf('--to');
113
+
114
+ // Resolve from/to — default: latest vs previous
115
+ let fromLabel = fromIdx !== -1 && args[fromIdx + 1] ? args[fromIdx + 1] : null;
116
+ let toLabel = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : null;
117
+
118
+ if (!fromLabel || !toLabel) {
119
+ const all = listBaselines();
120
+ if (all.length < 2) {
121
+ console.log('');
122
+ console.log(` ${paint.yellow('!')} Need at least 2 baselines to diff.`);
123
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor baseline save')} ${paint.dim('to create baselines.')}`);
124
+ console.log('');
125
+ return 1;
126
+ }
127
+ if (!fromLabel) fromLabel = all[all.length - 2].label;
128
+ if (!toLabel) toLabel = all[all.length - 1].label;
129
+ }
130
+
131
+ console.log(''); console.log(box('ClawArmor Baseline Diff')); console.log('');
132
+
133
+ let diff;
134
+ try {
135
+ diff = diffBaselines(fromLabel, toLabel);
136
+ } catch (e) {
137
+ console.log(` ${paint.red('✗')} ${e.message}`);
138
+ console.log('');
139
+ return 1;
140
+ }
141
+
142
+ const deltaStr = diff.scoreDelta > 0
143
+ ? paint.green(`+${diff.scoreDelta}`)
144
+ : diff.scoreDelta < 0
145
+ ? paint.red(String(diff.scoreDelta))
146
+ : paint.dim('0');
147
+
148
+ console.log(` ${paint.dim('From:')} ${paint.bold(diff.fromLabel)} ${paint.dim('(score: ' + diff.fromScore + ')')}`);
149
+ console.log(` ${paint.dim('To:')} ${paint.bold(diff.toLabel)} ${paint.dim('(score: ' + diff.toScore + ')')}`);
150
+ console.log(` ${paint.dim('Delta:')} ${deltaStr}`);
151
+ console.log('');
152
+
153
+ if (diff.newFindings.length) {
154
+ console.log(SEP);
155
+ console.log(` ${paint.yellow('New findings')} ${paint.dim('(' + diff.newFindings.length + ' since ' + diff.fromLabel + ')')}`);
156
+ console.log(SEP);
157
+ for (const f of diff.newFindings) {
158
+ const sev = f.severity || 'MEDIUM';
159
+ const sevColor = sev === 'CRITICAL' ? paint.red : sev === 'HIGH' ? paint.yellow : paint.cyan;
160
+ console.log(` ${sevColor('[' + sev + ']')} ${paint.bold(f.skill || '?')} ${paint.dim(f.message || f.patternId || '')}`);
161
+ }
162
+ console.log('');
163
+ } else {
164
+ console.log(` ${paint.green('✓')} No new findings.`);
165
+ }
166
+
167
+ if (diff.resolvedFindings.length) {
168
+ console.log(SEP);
169
+ console.log(` ${paint.green('Resolved findings')} ${paint.dim('(' + diff.resolvedFindings.length + ' fixed since ' + diff.fromLabel + ')')}`);
170
+ console.log(SEP);
171
+ for (const f of diff.resolvedFindings) {
172
+ console.log(` ${paint.dim('✓')} ${f.skill || '?'} ${paint.dim(f.message || f.patternId || '')}`);
173
+ }
174
+ console.log('');
175
+ }
176
+
177
+ if (!diff.newFindings.length && !diff.resolvedFindings.length) {
178
+ console.log(` ${paint.dim('No changes between baselines.')}`);
179
+ console.log('');
180
+ }
181
+
182
+ return 0;
183
+ }
184
+
185
+ console.log(` ${paint.red('✗')} Unknown baseline subcommand: ${paint.bold(sub)}`);
186
+ console.log(` ${paint.dim('Use: save | list | diff')}`);
187
+ console.log('');
188
+ return 1;
189
+ }
@@ -0,0 +1,106 @@
1
+ // ClawArmor baseline storage — save, list, and diff audit baselines.
2
+ // Baselines are stored in ~/.openclaw/workspace/memory/clawarmor-baselines/
3
+
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+
8
+ const BASELINE_DIR = join(homedir(), '.openclaw', 'workspace', 'memory', 'clawarmor-baselines');
9
+
10
+ function ensureDir() {
11
+ mkdirSync(BASELINE_DIR, { recursive: true });
12
+ }
13
+
14
+ function baselinePath(label) {
15
+ return join(BASELINE_DIR, `${label}.json`);
16
+ }
17
+
18
+ /**
19
+ * Save a baseline snapshot.
20
+ * @param {{ label: string, score: number, findings: any[], profile?: string }} data
21
+ */
22
+ export function saveBaseline({ label, score, findings, profile }) {
23
+ ensureDir();
24
+ const entry = {
25
+ label,
26
+ savedAt: new Date().toISOString(),
27
+ score,
28
+ findings: findings || [],
29
+ profile: profile || null,
30
+ };
31
+ writeFileSync(baselinePath(label), JSON.stringify(entry, null, 2), 'utf8');
32
+ return baselinePath(label);
33
+ }
34
+
35
+ /**
36
+ * List all saved baselines, sorted by savedAt ascending.
37
+ * @returns {Array<{ label, savedAt, score, profile, path }>}
38
+ */
39
+ export function listBaselines() {
40
+ if (!existsSync(BASELINE_DIR)) return [];
41
+ try {
42
+ const files = readdirSync(BASELINE_DIR).filter(f => f.endsWith('.json'));
43
+ const baselines = [];
44
+ for (const f of files) {
45
+ try {
46
+ const raw = JSON.parse(readFileSync(join(BASELINE_DIR, f), 'utf8'));
47
+ baselines.push({
48
+ label: raw.label || f.replace('.json', ''),
49
+ savedAt: raw.savedAt || null,
50
+ score: raw.score ?? null,
51
+ profile: raw.profile || null,
52
+ path: join(BASELINE_DIR, f),
53
+ });
54
+ } catch { /* skip malformed */ }
55
+ }
56
+ baselines.sort((a, b) => (a.savedAt || '').localeCompare(b.savedAt || ''));
57
+ return baselines;
58
+ } catch { return []; }
59
+ }
60
+
61
+ /**
62
+ * Load a baseline by label.
63
+ * @param {string} label
64
+ * @returns {object|null}
65
+ */
66
+ export function loadBaseline(label) {
67
+ const p = baselinePath(label);
68
+ if (!existsSync(p)) return null;
69
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
70
+ catch { return null; }
71
+ }
72
+
73
+ /**
74
+ * Diff two baselines.
75
+ * Returns { scoreDelta, newFindings, resolvedFindings, fromLabel, toLabel, fromScore, toScore }
76
+ */
77
+ export function diffBaselines(fromLabel, toLabel) {
78
+ const from = loadBaseline(fromLabel);
79
+ const to = loadBaseline(toLabel);
80
+
81
+ if (!from) throw new Error(`Baseline not found: ${fromLabel}`);
82
+ if (!to) throw new Error(`Baseline not found: ${toLabel}`);
83
+
84
+ const fromScore = from.score ?? 0;
85
+ const toScore = to.score ?? 0;
86
+ const scoreDelta = toScore - fromScore;
87
+
88
+ // Key findings by patternId+skill for comparison
89
+ const key = f => `${f.skill || ''}:${f.patternId || f.id || ''}:${f.severity || ''}`;
90
+
91
+ const fromKeys = new Set((from.findings || []).map(key));
92
+ const toKeys = new Set((to.findings || []).map(key));
93
+
94
+ const newFindings = (to.findings || []).filter(f => !fromKeys.has(key(f)));
95
+ const resolvedFindings = (from.findings || []).filter(f => !toKeys.has(key(f)));
96
+
97
+ return {
98
+ fromLabel,
99
+ toLabel,
100
+ fromScore,
101
+ toScore,
102
+ scoreDelta,
103
+ newFindings,
104
+ resolvedFindings,
105
+ };
106
+ }
package/lib/harden.js CHANGED
@@ -4,10 +4,11 @@
4
4
  // --dry-run: show what WOULD be fixed, no writes
5
5
  // --auto: apply all safe + caution fixes without confirmation (skips breaking)
6
6
  // --auto --force: apply ALL fixes including breaking ones
7
+ // --report [path]: write a structured report (JSON or Markdown) after hardening
7
8
 
8
- import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
9
- import { join } from 'path';
10
- import { homedir } from 'os';
9
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { homedir, platform, release } from 'os';
11
12
  import { execSync, spawnSync } from 'child_process';
12
13
  import { createInterface } from 'readline';
13
14
  import { paint } from './output/colors.js';
@@ -24,6 +25,8 @@ const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
24
25
  const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
25
26
  const SEP = paint.dim('─'.repeat(52));
26
27
 
28
+ const VERSION = '3.4.0';
29
+
27
30
  // ── Impact levels ─────────────────────────────────────────────────────────────
28
31
  // SAFE: No functionality impact. Pure security improvement.
29
32
  // CAUTION: May change agent behavior. User should be aware.
@@ -83,8 +86,6 @@ function buildFixes(config) {
83
86
  // Fix 1: world/group-readable credential files — chmod 600
84
87
  const badFiles = findWorldReadableCredFiles();
85
88
  for (const f of badFiles) {
86
- // Classify: credential files (tokens, keys) are safe to lock down.
87
- // Config files that other tools might read need caution.
88
89
  const isSensitive = /\.(env|json|key|pem|token|secret)$/i.test(f.name) ||
89
90
  f.name === 'agent-accounts.json' ||
90
91
  f.name === 'openclaw.json';
@@ -101,6 +102,9 @@ function buildFixes(config) {
101
102
  ? 'Only restricts other system users. Scripts will still run as you.'
102
103
  : 'Only restricts other system users from reading this file. Your agent is unaffected.',
103
104
  manualNote: null,
105
+ // for report: capture before state
106
+ _reportBefore: f.mode,
107
+ _reportAfter: '600',
104
108
  });
105
109
  }
106
110
 
@@ -119,6 +123,8 @@ function buildFixes(config) {
119
123
  ' tablet, or another computer), those connections will be blocked.\n' +
120
124
  ' Only localhost access will work after this change.',
121
125
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
126
+ _reportBefore: '0.0.0.0',
127
+ _reportAfter: '127.0.0.1',
122
128
  });
123
129
  }
124
130
 
@@ -137,6 +143,8 @@ function buildFixes(config) {
137
143
  ' shell commands may pause waiting for approval.\n' +
138
144
  ' You\'ll need to approve commands via the web UI or CLI.',
139
145
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
146
+ _reportBefore: String(execAsk),
147
+ _reportAfter: 'on-miss',
140
148
  });
141
149
  }
142
150
 
@@ -236,6 +244,171 @@ function printImpactSummary(fixes) {
236
244
  return parts.join(paint.dim(' · '));
237
245
  }
238
246
 
247
+ // ── Report support ────────────────────────────────────────────────────────────
248
+
249
+ function getSystemInfo() {
250
+ let osInfo = `${platform()} ${release()}`;
251
+ let ocVersion = 'unknown';
252
+ try {
253
+ const r = spawnSync('openclaw', ['--version'], { encoding: 'utf8', timeout: 5000 });
254
+ if (r.stdout) ocVersion = r.stdout.trim().split('\n')[0] || 'unknown';
255
+ } catch { /* non-fatal */ }
256
+ return { os: osInfo, openclaw_version: ocVersion };
257
+ }
258
+
259
+ function defaultReportPath(format) {
260
+ const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
261
+ const ext = format === 'text' ? 'md' : 'json';
262
+ return join(HOME, '.openclaw', `clawarmor-harden-report-${date}.${ext}`);
263
+ }
264
+
265
+ function buildReportItems({ fixes, applied, skipped, failed, skippedBreaking, applyResults }) {
266
+ const items = [];
267
+ const appliedSet = new Set(applied);
268
+ const skippedSet = new Set(skipped);
269
+ const failedSet = new Set(failed);
270
+
271
+ for (const fix of fixes) {
272
+ if (failedSet.has(fix.id)) {
273
+ const res = applyResults[fix.id];
274
+ items.push({
275
+ check: fix.id,
276
+ status: 'failed',
277
+ action: fix.description,
278
+ error: res?.err || 'unknown error',
279
+ });
280
+ } else if (skippedSet.has(fix.id)) {
281
+ const isBreaking = fix.impact === IMPACT.BREAKING;
282
+ items.push({
283
+ check: fix.id,
284
+ status: 'skipped',
285
+ skipped_reason: isBreaking
286
+ ? 'Breaking fix — skipped in auto mode (use --auto --force to include)'
287
+ : 'User declined',
288
+ });
289
+ } else if (appliedSet.has(fix.id)) {
290
+ items.push({
291
+ check: fix.id,
292
+ status: 'hardened',
293
+ before: fix._reportBefore ?? null,
294
+ after: fix._reportAfter ?? null,
295
+ action: fix.description,
296
+ });
297
+ }
298
+ }
299
+ return items;
300
+ }
301
+
302
+ function writeJsonReport(reportPath, items) {
303
+ const sysInfo = getSystemInfo();
304
+ const hardened = items.filter(i => i.status === 'hardened').length;
305
+ const already_good = items.filter(i => i.status === 'already_good').length;
306
+ const skipped = items.filter(i => i.status === 'skipped').length;
307
+ const failed = items.filter(i => i.status === 'failed').length;
308
+
309
+ const report = {
310
+ version: VERSION,
311
+ timestamp: new Date().toISOString(),
312
+ system: sysInfo,
313
+ summary: {
314
+ total_checks: items.length,
315
+ hardened,
316
+ already_good,
317
+ skipped,
318
+ failed,
319
+ },
320
+ items,
321
+ };
322
+
323
+ try { mkdirSync(dirname(reportPath), { recursive: true }); } catch {}
324
+ writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8');
325
+ return report;
326
+ }
327
+
328
+ function writeMarkdownReport(reportPath, items) {
329
+ const sysInfo = getSystemInfo();
330
+ const now = new Date();
331
+ const dateStr = now.toLocaleString('en-US', {
332
+ year: 'numeric', month: '2-digit', day: '2-digit',
333
+ hour: '2-digit', minute: '2-digit', hour12: false
334
+ });
335
+
336
+ const hardened = items.filter(i => i.status === 'hardened');
337
+ const alreadyGood = items.filter(i => i.status === 'already_good');
338
+ const skippedItems = items.filter(i => i.status === 'skipped');
339
+ const failedItems = items.filter(i => i.status === 'failed');
340
+
341
+ let md = `# ClawArmor Hardening Report
342
+ Generated: ${dateStr}
343
+ ClawArmor: v${VERSION} | OS: ${sysInfo.os} | OpenClaw: ${sysInfo.openclaw_version}
344
+
345
+ ## Summary
346
+ - ✅ ${alreadyGood.length} check${alreadyGood.length !== 1 ? 's' : ''} already good
347
+ - 🔧 ${hardened.length} hardened
348
+ - ⚠️ ${skippedItems.length} skipped
349
+ ${failedItems.length ? `- ❌ ${failedItems.length} failed\n` : ''}`;
350
+
351
+ if (hardened.length) {
352
+ md += `
353
+ ## Actions Taken
354
+
355
+ | Check | Before | After | Action |
356
+ |-------|--------|-------|--------|
357
+ `;
358
+ for (const item of hardened) {
359
+ md += `| ${item.check} | ${item.before ?? '—'} | ${item.after ?? '—'} | ${item.action ?? '—'} |\n`;
360
+ }
361
+ }
362
+
363
+ if (alreadyGood.length) {
364
+ md += `
365
+ ## Already Good
366
+
367
+ `;
368
+ for (const item of alreadyGood) {
369
+ md += `- ${item.check}\n`;
370
+ }
371
+ }
372
+
373
+ if (skippedItems.length) {
374
+ md += `
375
+ ## Skipped
376
+
377
+ `;
378
+ for (const item of skippedItems) {
379
+ md += `- **${item.check}**: ${item.skipped_reason || 'no reason given'}\n`;
380
+ }
381
+ }
382
+
383
+ if (failedItems.length) {
384
+ md += `
385
+ ## Failed
386
+
387
+ `;
388
+ for (const item of failedItems) {
389
+ md += `- **${item.check}**: ${item.error || 'unknown error'}\n`;
390
+ }
391
+ }
392
+
393
+ try { mkdirSync(dirname(reportPath), { recursive: true }); } catch {}
394
+ writeFileSync(reportPath, md, 'utf8');
395
+ }
396
+
397
+ function printReportSummary(items, reportPath, format) {
398
+ const hardened = items.filter(i => i.status === 'hardened').length;
399
+ const alreadyGood = items.filter(i => i.status === 'already_good').length;
400
+ const skipped = items.filter(i => i.status === 'skipped').length;
401
+ const failed = items.filter(i => i.status === 'failed').length;
402
+
403
+ console.log('');
404
+ console.log(SEP);
405
+ console.log(` ${paint.bold('Hardening Report')}`);
406
+ console.log(` ${paint.green('✅')} ${alreadyGood} already good ${paint.cyan('🔧')} ${hardened} hardened ${paint.yellow('⚠️')} ${skipped} skipped${failed ? ` ${paint.red('❌')} ${failed} failed` : ''}`);
407
+ console.log(` ${paint.dim('Report written:')} ${reportPath}`);
408
+ console.log(` ${paint.dim('Format:')} ${format === 'text' ? 'Markdown (.md)' : 'JSON'}`);
409
+ console.log('');
410
+ }
411
+
239
412
  // ── Main export ───────────────────────────────────────────────────────────────
240
413
 
241
414
  export async function runHarden(flags = {}) {
@@ -285,12 +458,9 @@ export async function runHarden(flags = {}) {
285
458
  const overrideSev = getOverriddenSeverity(profile.name, fix.id);
286
459
  const expected = isExpectedFinding(profile.name, fix.id);
287
460
  if (expected) {
288
- // Mark as expected — skip entirely from harden output
289
461
  return { ...fix, _skipForProfile: true };
290
462
  }
291
463
  if (overrideSev) {
292
- // Upgrade to higher severity if unexpected for this profile
293
- const currentWeight = { SAFE: 1, CAUTION: 2, BREAKING: 3 };
294
464
  const upgradeMap = { HIGH: IMPACT.BREAKING, MEDIUM: IMPACT.CAUTION, INFO: IMPACT.SAFE };
295
465
  const newImpact = upgradeMap[overrideSev] || fix.impact;
296
466
  return { ...fix, impact: newImpact, _profileOverride: overrideSev };
@@ -380,11 +550,24 @@ export async function runHarden(flags = {}) {
380
550
  console.log(` ${paint.dim('•')} ${item}`);
381
551
  }
382
552
  console.log('');
553
+
554
+ // If report requested but nothing to harden, still write an empty/all-good report
555
+ if (flags.report) {
556
+ const format = flags.reportFormat || 'json';
557
+ const reportPath = flags.reportPath || defaultReportPath(format);
558
+ const items = []; // no fixes, no items (could add "already_good" items if we tracked checks)
559
+ if (format === 'text') {
560
+ writeMarkdownReport(reportPath, items);
561
+ } else {
562
+ writeJsonReport(reportPath, items);
563
+ }
564
+ printReportSummary(items, reportPath, format);
565
+ }
566
+
383
567
  return 0;
384
568
  }
385
569
 
386
570
  if (flags.auto) {
387
- // --auto mode: apply safe + caution, SKIP breaking unless --force
388
571
  const autoLabel = flags.force
389
572
  ? paint.cyan('Auto mode (--force) — applying ALL fixes including breaking')
390
573
  : paint.cyan('Auto mode — applying safe + caution fixes (skipping breaking)');
@@ -406,6 +589,12 @@ export async function runHarden(flags = {}) {
406
589
  let applied = 0, skipped = 0, failed = 0, skippedBreaking = 0;
407
590
  const restartNotes = [];
408
591
 
592
+ // Report tracking
593
+ const appliedIds = [];
594
+ const skippedIds = [];
595
+ const failedIds = [];
596
+ const applyResults = {};
597
+
409
598
  for (const fix of fixes) {
410
599
  const badge = IMPACT_BADGE[fix.impact]();
411
600
 
@@ -420,17 +609,16 @@ export async function runHarden(flags = {}) {
420
609
  let doApply;
421
610
 
422
611
  if (flags.auto) {
423
- // In auto mode: apply safe + caution, skip breaking unless --force
424
612
  if (fix.impact === IMPACT.BREAKING && !flags.force) {
425
613
  console.log(` ${paint.red('⊘ Skipped')} ${paint.dim('(breaking — use --auto --force to include)')}`);
426
614
  skippedBreaking++;
427
615
  skipped++;
616
+ skippedIds.push(fix.id);
428
617
  console.log('');
429
618
  continue;
430
619
  }
431
620
  doApply = true;
432
621
  } else {
433
- // Interactive mode: always ask, but warn about breaking
434
622
  if (fix.impact === IMPACT.BREAKING) {
435
623
  console.log(` ${paint.red('⚠ This fix will change how your agent works.')}`);
436
624
  console.log(` ${paint.red(' Read the impact above carefully before applying.')}`);
@@ -442,18 +630,22 @@ export async function runHarden(flags = {}) {
442
630
  if (!doApply) {
443
631
  console.log(` ${paint.dim('✗ Skipped')}`);
444
632
  skipped++;
633
+ skippedIds.push(fix.id);
445
634
  console.log('');
446
635
  continue;
447
636
  }
448
637
 
449
638
  const result = applyFix(fix);
639
+ applyResults[fix.id] = result;
450
640
  if (result.ok) {
451
641
  console.log(` ${paint.green('✓ Fixed')}`);
452
642
  applied++;
643
+ appliedIds.push(fix.id);
453
644
  if (fix.manualNote) restartNotes.push(fix.manualNote);
454
645
  } else {
455
646
  console.log(` ${paint.red('✗ Failed:')} ${result.err}`);
456
647
  failed++;
648
+ failedIds.push(fix.id);
457
649
  }
458
650
  console.log('');
459
651
  }
@@ -505,6 +697,28 @@ export async function runHarden(flags = {}) {
505
697
  }
506
698
  }
507
699
 
700
+ // ── Write report if requested ──────────────────────────────────────────────
701
+ if (flags.report) {
702
+ const format = flags.reportFormat || 'json';
703
+ const reportPath = flags.reportPath || defaultReportPath(format);
704
+
705
+ const reportItems = buildReportItems({
706
+ fixes,
707
+ applied: appliedIds,
708
+ skipped: skippedIds,
709
+ failed: failedIds,
710
+ applyResults,
711
+ });
712
+
713
+ if (format === 'text') {
714
+ writeMarkdownReport(reportPath, reportItems);
715
+ } else {
716
+ writeJsonReport(reportPath, reportItems);
717
+ }
718
+
719
+ printReportSummary(reportItems, reportPath, format);
720
+ }
721
+
508
722
  console.log('');
509
723
  return failed > 0 ? 1 : 0;
510
724
  }