clawarmor 2.0.0-alpha.3 → 2.1.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.
package/demo.gif ADDED
Binary file
package/lib/fix.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // clawarmor fix — auto-apply safe one-liner fixes
2
+ // Now with impact classification: safe / caution / breaking
2
3
  import { readFileSync } from 'fs';
3
4
  import { homedir } from 'os';
4
5
  import { join } from 'path';
@@ -9,57 +10,90 @@ import { loadConfig, get } from './config.js';
9
10
  const HISTORY_PATH = join(homedir(), '.clawarmor', 'history.json');
10
11
  const SEP = paint.dim('─'.repeat(52));
11
12
 
13
+ // Impact levels
14
+ const IMPACT = { SAFE: 'safe', CAUTION: 'caution', BREAKING: 'breaking' };
15
+
16
+ const IMPACT_BADGE = {
17
+ [IMPACT.SAFE]: () => paint.green('🟢 Safe'),
18
+ [IMPACT.CAUTION]: () => paint.yellow('🟡 Caution'),
19
+ [IMPACT.BREAKING]: () => paint.red('🔴 Breaking'),
20
+ };
21
+
12
22
  // Fixes that are safe to auto-apply (single config set command, no restart risk)
13
23
  const AUTO_FIXABLE = {
14
24
  'browser.ssrf': {
15
25
  cmd: 'openclaw config set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork false',
16
26
  desc: 'Block browser SSRF to private networks',
17
27
  needsRestart: true,
28
+ impact: IMPACT.CAUTION,
29
+ impactDetail: 'Browser tool will no longer be able to access local/private network URLs.\n' +
30
+ ' If your agent browses internal dashboards or local services, those will be blocked.',
18
31
  },
19
32
  'discovery.mdns': {
20
33
  cmd: 'openclaw config set discovery.mdns.mode minimal',
21
34
  desc: 'Set mDNS to minimal mode',
22
35
  needsRestart: true,
36
+ impact: IMPACT.SAFE,
37
+ impactDetail: 'Only reduces network advertisement. No functionality change.',
23
38
  },
24
39
  'logging.redact': {
25
40
  cmd: 'openclaw config set logging.redactSensitive tools',
26
41
  desc: 'Enable log redaction',
27
42
  needsRestart: false,
43
+ impact: IMPACT.SAFE,
44
+ impactDetail: 'Redacts sensitive data from logs. No functionality change.',
28
45
  },
29
46
  'tools.fs.workspaceOnly': {
30
47
  cmd: 'openclaw config set tools.fs.workspaceOnly true',
31
48
  desc: 'Restrict filesystem to workspace',
32
49
  needsRestart: true,
50
+ impact: IMPACT.BREAKING,
51
+ impactDetail: 'Agent will ONLY be able to read/write files inside the workspace directory.\n' +
52
+ ' Access to home directory, system files, and other paths will be blocked.\n' +
53
+ ' Skills or workflows that read files outside workspace will break.',
33
54
  },
34
55
  'tools.applyPatch.workspaceOnly': {
35
56
  cmd: 'openclaw config set tools.exec.applyPatch.workspaceOnly true',
36
57
  desc: 'Restrict apply_patch to workspace',
37
58
  needsRestart: true,
59
+ impact: IMPACT.CAUTION,
60
+ impactDetail: 'Patches can only be applied to files in the workspace. Usually fine unless\n' +
61
+ ' your agent patches system files or configs outside the workspace.',
38
62
  },
39
63
  'fs.config.perms': {
40
64
  cmd: 'chmod 600 ~/.openclaw/openclaw.json',
41
65
  desc: 'Lock down config file permissions',
42
66
  needsRestart: false,
43
67
  shell: true,
68
+ impact: IMPACT.SAFE,
69
+ impactDetail: 'Only restricts other system users. Your agent runs as you.',
44
70
  },
45
71
  'fs.accounts.perms': {
46
72
  cmd: 'chmod 600 ~/.openclaw/agent-accounts.json',
47
73
  desc: 'Lock down credentials file permissions',
48
74
  needsRestart: false,
49
75
  shell: true,
76
+ impact: IMPACT.SAFE,
77
+ impactDetail: 'Only restricts other system users. Your agent runs as you.',
50
78
  },
51
79
  'agents.sandbox': {
52
- // Multi-step fix: enables sandbox WITH workspace access so Telegram sessions don't lose memory files
53
80
  cmd: "openclaw config set agents.defaults.sandbox.mode non-main && openclaw config set agents.defaults.sandbox.workspaceAccess rw && openclaw config set agents.defaults.sandbox.scope session",
54
81
  desc: 'Enable sandbox isolation (with workspace access preserved for Telegram/group sessions)',
55
82
  needsRestart: true,
56
83
  requiresDocker: true,
84
+ impact: IMPACT.BREAKING,
85
+ impactDetail: 'Non-main sessions will run inside Docker containers.\n' +
86
+ ' Requires Docker Desktop to be installed and running.\n' +
87
+ ' Telegram/group sessions will lose direct host access (shell commands,\n' +
88
+ ' file reads outside workspace). Workspace files remain accessible.',
57
89
  },
58
90
  'fs.dir.perms': {
59
91
  cmd: 'chmod 700 ~/.openclaw',
60
92
  desc: 'Lock down ~/.openclaw directory',
61
93
  needsRestart: false,
62
94
  shell: true,
95
+ impact: IMPACT.SAFE,
96
+ impactDetail: 'Only restricts other system users from listing the directory.',
63
97
  },
64
98
  };
65
99
 
@@ -71,7 +105,7 @@ function box(title) {
71
105
  }
72
106
 
73
107
  export async function runFix(flags = {}) {
74
- console.log(''); console.log(box('ClawArmor Fix v1.1.0')); console.log('');
108
+ console.log(''); console.log(box('ClawArmor Fix v2.1')); console.log('');
75
109
 
76
110
  // Load last audit failures
77
111
  let lastFailed = [];
@@ -89,8 +123,13 @@ export async function runFix(flags = {}) {
89
123
  const fixable = lastFailed.filter(id => AUTO_FIXABLE[id]);
90
124
  const manual = lastFailed.filter(id => !AUTO_FIXABLE[id]);
91
125
 
126
+ const safeFixes = fixable.filter(id => AUTO_FIXABLE[id].impact === IMPACT.SAFE);
127
+ const cautionFixes = fixable.filter(id => AUTO_FIXABLE[id].impact === IMPACT.CAUTION);
128
+ const breakingFixes = fixable.filter(id => AUTO_FIXABLE[id].impact === IMPACT.BREAKING);
129
+
92
130
  console.log(` ${paint.dim('Last audit had')} ${paint.bold(String(lastFailed.length))} ${paint.dim('failing checks.')}`);
93
- console.log(` ${paint.bold(String(fixable.length))} ${paint.dim('can be auto-fixed.')} ${paint.dim(String(manual.length))} ${paint.dim('require manual steps.')}`);
131
+ console.log(` ${paint.bold(String(fixable.length))} ${paint.dim('can be auto-fixed:')} ${paint.green(String(safeFixes.length) + ' safe')} ${paint.dim('·')} ${paint.yellow(String(cautionFixes.length) + ' caution')} ${paint.dim('·')} ${paint.red(String(breakingFixes.length) + ' breaking')}`);
132
+ console.log(` ${paint.dim(String(manual.length))} ${paint.dim('require manual steps.')}`);
94
133
 
95
134
  if (!fixable.length) {
96
135
  console.log('');
@@ -99,40 +138,60 @@ export async function runFix(flags = {}) {
99
138
  console.log(''); return 0;
100
139
  }
101
140
 
102
- // Load config for mainKey check (needed by both dry-run and apply paths)
141
+ // Load config for mainKey check
103
142
  const { config: cfg } = loadConfig();
104
143
  const mainKey = get(cfg, 'agents.mainKey', 'main');
105
144
 
106
145
  console.log('');
107
146
  if (flags.dryRun) {
108
147
  console.log(` ${paint.cyan('Dry run — would apply:')}`);
148
+ console.log('');
109
149
  for (const id of fixable) {
110
150
  const f = AUTO_FIXABLE[id];
151
+ const badge = IMPACT_BADGE[f.impact]();
111
152
  if (id === 'agents.sandbox' && mainKey !== 'main') {
112
153
  console.log(` ${paint.yellow('⚠')} ${paint.bold('Custom mainKey detected:')} agents.mainKey="${mainKey}"`);
113
154
  console.log(` ${paint.dim('Verify that sandbox.mode=non-main won\'t affect your main session.')}`);
114
155
  }
115
- console.log(` ${paint.dim('→')} ${f.desc}`);
116
- console.log(` ${paint.dim(f.cmd)}`);
117
- if (f.requiresDocker) console.log(` ${paint.yellow('⚠')} ${paint.dim('Requires Docker Desktop to be installed and running')}`);
118
- if (f.needsRestart) console.log(` ${paint.dim(' gateway restart required after applying')}`);
156
+ console.log(` ${badge} ${paint.dim('→')} ${f.desc}`);
157
+ console.log(` ${paint.dim('Impact:')} ${f.impactDetail}`);
158
+ console.log(` ${paint.dim(f.cmd)}`);
159
+ if (f.requiresDocker) console.log(` ${paint.yellow('⚠')} ${paint.dim('Requires Docker Desktop to be installed and running')}`);
160
+ if (f.needsRestart) console.log(` ${paint.dim('↻ gateway restart required after applying')}`);
161
+ console.log('');
162
+ }
163
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply')} ${paint.dim('to apply safe + caution fixes.')}`);
164
+ if (breakingFixes.length) {
165
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply --force')} ${paint.dim('to apply ALL fixes (including breaking).')}`);
119
166
  }
120
- console.log('');
121
- console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply')} ${paint.dim('to apply these fixes.')}`);
122
167
  console.log(''); return 0;
123
168
  }
124
169
 
125
170
  if (!flags.apply) {
126
- console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --dry-run')} ${paint.dim('to preview fixes.')}`);
127
- console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply')} ${paint.dim('to apply them.')}`);
171
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --dry-run')} ${paint.dim('to preview fixes with impact analysis.')}`);
172
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply')} ${paint.dim('to apply safe + caution fixes.')}`);
173
+ if (breakingFixes.length) {
174
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor fix --apply --force')} ${paint.dim('to apply ALL fixes (including breaking).')}`);
175
+ }
128
176
  console.log(''); return 0;
129
177
  }
130
178
 
131
179
  // Apply fixes
132
- let applied = 0, failed = 0, needsRestart = false;
180
+ let applied = 0, failed = 0, skippedBreaking = 0, needsRestart = false;
133
181
  console.log(SEP);
134
182
  for (const id of fixable) {
135
183
  const f = AUTO_FIXABLE[id];
184
+ const badge = IMPACT_BADGE[f.impact]();
185
+
186
+ // Skip breaking unless --force
187
+ if (f.impact === IMPACT.BREAKING && !flags.force) {
188
+ console.log(` ${badge} ${f.desc}`);
189
+ console.log(` ${paint.dim('Impact:')} ${f.impactDetail}`);
190
+ console.log(` ${paint.red('⊘ Skipped')} ${paint.dim('(breaking — use --apply --force to include)')}`);
191
+ console.log('');
192
+ skippedBreaking++;
193
+ continue;
194
+ }
136
195
 
137
196
  // Warn about custom mainKey before sandbox fix
138
197
  if (id === 'agents.sandbox' && mainKey !== 'main') {
@@ -140,7 +199,7 @@ export async function runFix(flags = {}) {
140
199
  console.log(` ${paint.dim('Verify that sandbox.mode=non-main won\'t affect your main session.')}`);
141
200
  }
142
201
 
143
- process.stdout.write(` ${paint.dim('→')} ${f.desc}...`);
202
+ process.stdout.write(` ${badge} ${f.desc}...`);
144
203
  // Check Docker requirement
145
204
  if (f.requiresDocker) {
146
205
  const dockerCheck = spawnSync('docker', ['info'], { stdio: 'ignore' });
@@ -162,6 +221,9 @@ export async function runFix(flags = {}) {
162
221
  }
163
222
 
164
223
  console.log('');
224
+ if (skippedBreaking > 0) {
225
+ console.log(` ${paint.dim(`${skippedBreaking} breaking fix${skippedBreaking !== 1 ? 'es' : ''} skipped — use --apply --force to include`)}`);
226
+ }
165
227
  if (needsRestart) {
166
228
  console.log(` ${paint.yellow('!')} ${paint.bold('Restart required:')} ${paint.dim('openclaw gateway restart')}`);
167
229
  }
package/lib/harden.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Modes:
3
3
  // default: show each fix, prompt y/N before applying
4
4
  // --dry-run: show what WOULD be fixed, no writes
5
- // --auto: apply all safe fixes without confirmation (CI mode)
5
+ // --auto: apply all safe + caution fixes without confirmation (skips breaking)
6
+ // --auto --force: apply ALL fixes including breaking ones
6
7
 
7
8
  import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
8
9
  import { join } from 'path';
@@ -20,6 +21,29 @@ const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
20
21
  const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
21
22
  const SEP = paint.dim('─'.repeat(52));
22
23
 
24
+ // ── Impact levels ─────────────────────────────────────────────────────────────
25
+ // SAFE: No functionality impact. Pure security improvement.
26
+ // CAUTION: May change agent behavior. User should be aware.
27
+ // BREAKING: Will disable or restrict features currently in use.
28
+
29
+ const IMPACT = {
30
+ SAFE: 'safe',
31
+ CAUTION: 'caution',
32
+ BREAKING: 'breaking',
33
+ };
34
+
35
+ const IMPACT_BADGE = {
36
+ [IMPACT.SAFE]: (s) => paint.green(`🟢 Safe`),
37
+ [IMPACT.CAUTION]: (s) => paint.yellow(`🟡 Caution`),
38
+ [IMPACT.BREAKING]: (s) => paint.red(`🔴 Breaking`),
39
+ };
40
+
41
+ const IMPACT_LABEL = {
42
+ [IMPACT.SAFE]: 'No functionality impact',
43
+ [IMPACT.CAUTION]: 'May change agent behavior',
44
+ [IMPACT.BREAKING]: 'Will disable or restrict features you\'re actively using',
45
+ };
46
+
23
47
  function box(title) {
24
48
  const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
25
49
  return [
@@ -56,12 +80,23 @@ function buildFixes(config) {
56
80
  // Fix 1: world/group-readable credential files — chmod 600
57
81
  const badFiles = findWorldReadableCredFiles();
58
82
  for (const f of badFiles) {
83
+ // Classify: credential files (tokens, keys) are safe to lock down.
84
+ // Config files that other tools might read need caution.
85
+ const isSensitive = /\.(env|json|key|pem|token|secret)$/i.test(f.name) ||
86
+ f.name === 'agent-accounts.json' ||
87
+ f.name === 'openclaw.json';
88
+ const isScript = /\.(py|sh|js|ts)$/i.test(f.name);
89
+
59
90
  fixes.push({
60
91
  id: `cred.perms.${f.name}`,
61
92
  problem: `${f.name} is readable by other users (permissions: ${f.mode})`,
62
93
  action: `chmod 600 ${f.path}`,
63
94
  description: `Set permissions to 600 (owner-only) on ${f.path}`,
64
95
  type: 'shell',
96
+ impact: IMPACT.SAFE,
97
+ impactDetail: isScript
98
+ ? 'Only restricts other system users. Scripts will still run as you.'
99
+ : 'Only restricts other system users from reading this file. Your agent is unaffected.',
65
100
  manualNote: null,
66
101
  });
67
102
  }
@@ -75,6 +110,11 @@ function buildFixes(config) {
75
110
  action: 'openclaw config set gateway.host 127.0.0.1',
76
111
  description: 'Change gateway.host to 127.0.0.1 (loopback only)',
77
112
  type: 'openclaw',
113
+ impact: IMPACT.BREAKING,
114
+ impactDetail: 'Remote connections to the gateway will stop working.\n' +
115
+ ' If you access OpenClaw from other devices on your network (e.g. phone,\n' +
116
+ ' tablet, or another computer), those connections will be blocked.\n' +
117
+ ' Only localhost access will work after this change.',
78
118
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
79
119
  });
80
120
  }
@@ -85,9 +125,14 @@ function buildFixes(config) {
85
125
  fixes.push({
86
126
  id: 'exec.ask.off',
87
127
  problem: 'exec.ask is off — shell commands run without user confirmation',
88
- action: 'openclaw config set exec.ask always',
89
- description: 'Enable exec.ask so shell commands require confirmation',
128
+ action: 'openclaw config set tools.exec.ask on-miss',
129
+ description: 'Enable exec approval for unrecognized commands',
90
130
  type: 'openclaw',
131
+ impact: IMPACT.BREAKING,
132
+ impactDetail: 'Your agent will need approval for shell commands not in the allowlist.\n' +
133
+ ' Autonomous workflows (cron jobs, background tasks, sub-agents) that run\n' +
134
+ ' shell commands may pause waiting for approval.\n' +
135
+ ' You\'ll need to approve commands via the web UI or CLI.',
91
136
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
92
137
  });
93
138
  }
@@ -157,10 +202,24 @@ function getManualItems() {
157
202
  ];
158
203
  }
159
204
 
205
+ // ── Print impact summary ──────────────────────────────────────────────────────
206
+
207
+ function printImpactSummary(fixes) {
208
+ const counts = { [IMPACT.SAFE]: 0, [IMPACT.CAUTION]: 0, [IMPACT.BREAKING]: 0 };
209
+ for (const fix of fixes) counts[fix.impact]++;
210
+
211
+ const parts = [];
212
+ if (counts[IMPACT.SAFE]) parts.push(paint.green(`${counts[IMPACT.SAFE]} safe`));
213
+ if (counts[IMPACT.CAUTION]) parts.push(paint.yellow(`${counts[IMPACT.CAUTION]} caution`));
214
+ if (counts[IMPACT.BREAKING]) parts.push(paint.red(`${counts[IMPACT.BREAKING]} breaking`));
215
+
216
+ return parts.join(paint.dim(' · '));
217
+ }
218
+
160
219
  // ── Main export ───────────────────────────────────────────────────────────────
161
220
 
162
221
  export async function runHarden(flags = {}) {
163
- console.log(''); console.log(box('ClawArmor Harden v2.0')); console.log('');
222
+ console.log(''); console.log(box('ClawArmor Harden v2.1')); console.log('');
164
223
 
165
224
  const { config } = loadConfig();
166
225
  const fixes = buildFixes(config);
@@ -170,6 +229,11 @@ export async function runHarden(flags = {}) {
170
229
  const beforeScore = before?.score ?? null;
171
230
  const beforeGrade = before?.grade ?? null;
172
231
 
232
+ // Count by impact
233
+ const safeFixes = fixes.filter(f => f.impact === IMPACT.SAFE);
234
+ const cautionFixes = fixes.filter(f => f.impact === IMPACT.CAUTION);
235
+ const breakingFixes = fixes.filter(f => f.impact === IMPACT.BREAKING);
236
+
173
237
  // ── DRY RUN ────────────────────────────────────────────────────────────────
174
238
  if (flags.dryRun) {
175
239
  console.log(` ${paint.cyan('Dry run — showing what would be fixed (no changes applied):')}`);
@@ -183,17 +247,23 @@ export async function runHarden(flags = {}) {
183
247
  }
184
248
 
185
249
  for (const fix of fixes) {
250
+ const badge = IMPACT_BADGE[fix.impact]();
186
251
  console.log(` ${paint.yellow('!')} ${paint.bold(fix.problem)}`);
187
252
  console.log(` ${paint.dim('Fix:')} ${fix.description}`);
188
253
  console.log(` ${paint.dim('Cmd:')} ${fix.action}`);
254
+ console.log(` ${badge}${paint.dim(':')} ${fix.impactDetail}`);
189
255
  if (fix.manualNote) console.log(` ${paint.dim('Note:')} ${fix.manualNote}`);
190
256
  console.log('');
191
257
  }
192
258
 
193
259
  console.log(SEP);
194
- console.log(` ${fixes.length} fix${fixes.length !== 1 ? 'es' : ''} available.`);
260
+ console.log(` ${fixes.length} fix${fixes.length !== 1 ? 'es' : ''} available: ${printImpactSummary(fixes)}`);
261
+ console.log('');
195
262
  console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden')} ${paint.dim('to apply interactively.')}`);
196
- console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --auto')} ${paint.dim('to apply all without prompts.')}`);
263
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --auto')} ${paint.dim('to apply safe + caution fixes.')}`);
264
+ if (breakingFixes.length) {
265
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --auto --force')} ${paint.dim('to apply ALL fixes (including breaking).')}`);
266
+ }
197
267
  console.log('');
198
268
 
199
269
  const manualItems = getManualItems();
@@ -223,25 +293,52 @@ export async function runHarden(flags = {}) {
223
293
  return 0;
224
294
  }
225
295
 
226
- const modeLabel = flags.auto
227
- ? paint.cyan('Auto mode applying all safe fixes without confirmation')
228
- : paint.cyan('Interactive mode — review and apply fixes one by one');
229
- console.log(` ${modeLabel}`);
296
+ if (flags.auto) {
297
+ // --auto mode: apply safe + caution, SKIP breaking unless --force
298
+ const autoLabel = flags.force
299
+ ? paint.cyan('Auto mode (--force) — applying ALL fixes including breaking')
300
+ : paint.cyan('Auto mode — applying safe + caution fixes (skipping breaking)');
301
+ console.log(` ${autoLabel}`);
302
+ console.log(` ${paint.dim('Fixes found:')} ${printImpactSummary(fixes)}`);
303
+ } else {
304
+ console.log(` ${paint.cyan('Interactive mode — review and apply fixes one by one')}`);
305
+ console.log(` ${paint.dim('Fixes found:')} ${printImpactSummary(fixes)}`);
306
+ }
230
307
  console.log('');
231
308
 
232
- let applied = 0, skipped = 0, failed = 0;
309
+ let applied = 0, skipped = 0, failed = 0, skippedBreaking = 0;
233
310
  const restartNotes = [];
234
311
 
235
312
  for (const fix of fixes) {
313
+ const badge = IMPACT_BADGE[fix.impact]();
314
+
236
315
  console.log(SEP);
316
+ console.log(` ${badge}`);
237
317
  console.log(` ${paint.bold('Problem:')} ${fix.problem}`);
238
318
  console.log(` ${paint.dim('Fix:')} ${fix.description}`);
319
+ console.log(` ${paint.dim('Impact:')} ${fix.impactDetail}`);
239
320
  console.log(` ${paint.dim('Command:')} ${paint.dim(fix.action)}`);
240
321
  console.log('');
241
322
 
242
- let doApply = flags.auto;
323
+ let doApply;
243
324
 
244
- if (!flags.auto) {
325
+ if (flags.auto) {
326
+ // In auto mode: apply safe + caution, skip breaking unless --force
327
+ if (fix.impact === IMPACT.BREAKING && !flags.force) {
328
+ console.log(` ${paint.red('⊘ Skipped')} ${paint.dim('(breaking — use --auto --force to include)')}`);
329
+ skippedBreaking++;
330
+ skipped++;
331
+ console.log('');
332
+ continue;
333
+ }
334
+ doApply = true;
335
+ } else {
336
+ // Interactive mode: always ask, but warn about breaking
337
+ if (fix.impact === IMPACT.BREAKING) {
338
+ console.log(` ${paint.red('⚠ This fix will change how your agent works.')}`);
339
+ console.log(` ${paint.red(' Read the impact above carefully before applying.')}`);
340
+ console.log('');
341
+ }
245
342
  doApply = await askYN(` Apply this fix? [y/N] `);
246
343
  }
247
344
 
@@ -268,6 +365,10 @@ export async function runHarden(flags = {}) {
268
365
  console.log('');
269
366
  console.log(` Applied: ${paint.green(String(applied))} Skipped: ${paint.dim(String(skipped))} Failed: ${failed > 0 ? paint.red(String(failed)) : paint.dim('0')}`);
270
367
 
368
+ if (skippedBreaking > 0) {
369
+ console.log(` ${paint.dim(`(${skippedBreaking} breaking fix${skippedBreaking !== 1 ? 'es' : ''} skipped — use --auto --force to include)`)}`);
370
+ }
371
+
271
372
  // Restart notes
272
373
  if (restartNotes.length) {
273
374
  console.log('');