claude-flow 3.48.0 → 3.49.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.
Files changed (51) hide show
  1. package/.claude/.proven-config-version +1 -0
  2. package/.claude/helpers/hook-handler.cjs +7 -4
  3. package/.claude/helpers/memory.cjs +1 -1
  4. package/.claude/helpers/router.cjs +1 -1
  5. package/.claude/helpers/session.cjs +1 -1
  6. package/.claude/proven-config.json +42 -0
  7. package/.claude-plugin/marketplace.json +16 -1
  8. package/README.md +1 -53
  9. package/README.zh-CN.md +1 -53
  10. package/node_modules/@claude-flow/codex/package.json +1 -1
  11. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +2 -6
  12. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  13. package/node_modules/@claude-flow/security/dist/policy/engine.js +35 -1
  14. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  15. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +18 -0
  16. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  17. package/node_modules/@claude-flow/security/package.json +1 -1
  18. package/package.json +2 -2
  19. package/v3/@claude-flow/cli/README.md +3 -53
  20. package/v3/@claude-flow/cli/catalog-manifest.json +4 -4
  21. package/v3/@claude-flow/cli/dist/src/commands/doctor.d.ts +19 -1
  22. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +69 -8
  23. package/v3/@claude-flow/cli/dist/src/commands/hooks.js +7 -4
  24. package/v3/@claude-flow/cli/dist/src/commands/memory.js +30 -7
  25. package/v3/@claude-flow/cli/dist/src/commands/plugins.js +44 -7
  26. package/v3/@claude-flow/cli/dist/src/commands/policy.js +5 -2
  27. package/v3/@claude-flow/cli/dist/src/commands/session.js +128 -21
  28. package/v3/@claude-flow/cli/dist/src/commands/swarm.js +11 -11
  29. package/v3/@claude-flow/cli/dist/src/index.js +10 -1
  30. package/v3/@claude-flow/cli/dist/src/init/executor.js +11 -5
  31. package/v3/@claude-flow/cli/dist/src/init/helper-companions.d.ts +3 -0
  32. package/v3/@claude-flow/cli/dist/src/init/helper-companions.js +34 -0
  33. package/v3/@claude-flow/cli/dist/src/init/helper-integrity.d.ts +21 -0
  34. package/v3/@claude-flow/cli/dist/src/init/helper-integrity.js +62 -0
  35. package/v3/@claude-flow/cli/dist/src/init/helper-refresh.d.ts +18 -11
  36. package/v3/@claude-flow/cli/dist/src/init/helper-refresh.js +56 -13
  37. package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +10 -10
  38. package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.d.ts +16 -3
  39. package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +87 -14
  40. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.d.ts +7 -0
  41. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +43 -6
  42. package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.d.ts +15 -0
  43. package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +236 -50
  44. package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +6 -3
  45. package/v3/@claude-flow/cli/dist/src/plugins/manager.d.ts +37 -10
  46. package/v3/@claude-flow/cli/dist/src/plugins/manager.js +106 -20
  47. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.d.ts +61 -0
  48. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.js +84 -0
  49. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +6 -0
  50. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +27 -2
  51. package/v3/@claude-flow/cli/package.json +2 -2
@@ -28,6 +28,33 @@ function formatDate(dateStr) {
28
28
  // Otherwise show date
29
29
  return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
30
30
  }
31
+ /**
32
+ * #3575: the session MCP tools report failure as `{ error }` (or
33
+ * `success: false`) rather than throwing, so every call site must check before
34
+ * printing a success line or reading result fields.
35
+ */
36
+ function toolError(result) {
37
+ if (!result || typeof result !== 'object')
38
+ return 'the tool returned no result';
39
+ const r = result;
40
+ if (typeof r.error === 'string' && r.error.length > 0)
41
+ return r.error;
42
+ if (r.success === false)
43
+ return 'the operation failed';
44
+ return null;
45
+ }
46
+ /** #3573: say what happened to memory instead of printing an invented 0. */
47
+ function memoryCaptureLabel(capture, fallback) {
48
+ if (!capture)
49
+ return fallback ?? 'unknown';
50
+ if (!capture.requested)
51
+ return 'not included';
52
+ if (capture.status === 'error')
53
+ return `not captured (${capture.error || 'error'})`;
54
+ if (capture.status === 'no-store')
55
+ return '0 (no memory store found)';
56
+ return capture.entries;
57
+ }
31
58
  // Format session status
32
59
  function formatStatus(status) {
33
60
  switch (status) {
@@ -186,6 +213,12 @@ const saveCommand = {
186
213
  includeAgents: ctx.flags['include-agents'] !== false,
187
214
  includeTasks: ctx.flags['include-tasks'] !== false
188
215
  });
216
+ const failure = toolError(result);
217
+ if (failure) {
218
+ spinner.fail('Failed to save session');
219
+ output.printError(failure);
220
+ return { success: false, exitCode: 1 };
221
+ }
189
222
  spinner.succeed('Session saved');
190
223
  output.writeln();
191
224
  const stats = result.stats || {};
@@ -201,10 +234,13 @@ const saveCommand = {
201
234
  { property: 'Saved At', value: new Date(result.savedAt).toLocaleString() },
202
235
  { property: 'Agents', value: stats.agentCount ?? stats.agents ?? 0 },
203
236
  { property: 'Tasks', value: stats.taskCount ?? stats.tasks ?? 0 },
204
- { property: 'Memory Entries', value: stats.memoryEntries ?? 0 },
237
+ { property: 'Memory Entries', value: memoryCaptureLabel(result.memoryCapture, stats.memoryEntries) },
205
238
  { property: 'Total Size', value: formatSize(stats.totalSize ?? 0) }
206
239
  ]
207
240
  });
241
+ if (result.memoryCapture?.status === 'error') {
242
+ output.printWarning(`Memory was requested but could not be captured: ${result.memoryCapture.error}`);
243
+ }
208
244
  output.writeln();
209
245
  output.printSuccess(`Session saved: ${result.sessionId}`);
210
246
  output.printInfo(`Restore with: claude-flow session restore ${result.sessionId}`);
@@ -313,8 +349,25 @@ const restoreCommand = {
313
349
  restoreAgents,
314
350
  restoreTasks
315
351
  });
316
- spinner.succeed('Session restored');
352
+ const failure = toolError(result) ?? (result.restored === false ? 'Session not found' : null);
353
+ if (failure) {
354
+ spinner.fail('Failed to restore session');
355
+ output.printError(failure);
356
+ return { success: false, exitCode: 1 };
357
+ }
358
+ const memoryRestore = result.memoryRestore;
359
+ const memoryPartial = !!memoryRestore && memoryRestore.failed > 0;
360
+ if (memoryPartial) {
361
+ spinner.fail('Session restored with memory errors');
362
+ }
363
+ else {
364
+ spinner.succeed('Session restored');
365
+ }
317
366
  output.writeln();
367
+ // #3573: distinguish "not requested" from "the session holds no memory".
368
+ const memoryStatus = result.restoredComponents.memory
369
+ ? (memoryPartial ? output.error('Partial') : output.success('Restored'))
370
+ : restoreMemory ? output.dim('Not in session') : output.dim('Skipped');
318
371
  output.printTable({
319
372
  columns: [
320
373
  { key: 'component', header: 'Component', width: 20 },
@@ -324,8 +377,10 @@ const restoreCommand = {
324
377
  data: [
325
378
  {
326
379
  component: 'Memory',
327
- status: result.restoredComponents.memory ? output.success('Restored') : output.dim('Skipped'),
328
- count: result.restoredComponents.memory ? result.stats.memoryEntries : 0
380
+ status: memoryStatus,
381
+ count: result.restoredComponents.memory
382
+ ? (result.stats.memoryEntriesRestored ?? result.stats.memoryEntries)
383
+ : 0
329
384
  },
330
385
  {
331
386
  component: 'Agents',
@@ -340,11 +395,19 @@ const restoreCommand = {
340
395
  ]
341
396
  });
342
397
  output.writeln();
343
- output.printSuccess(`Session ${sessionId} restored successfully`);
398
+ if (memoryPartial) {
399
+ output.printWarning(`${memoryRestore.failed} memory entries could not be restored` +
400
+ (memoryRestore.errors.length ? `: ${memoryRestore.errors.join('; ')}` : ''));
401
+ }
402
+ else {
403
+ output.printSuccess(`Session ${sessionId} restored successfully`);
404
+ }
344
405
  if (ctx.flags.format === 'json') {
345
406
  output.printJson(result);
346
407
  }
347
- return { success: true, data: result };
408
+ return memoryPartial
409
+ ? { success: false, exitCode: 1, data: result }
410
+ : { success: true, data: result };
348
411
  }
349
412
  catch (error) {
350
413
  spinner.fail('Failed to restore session');
@@ -391,6 +454,11 @@ const deleteCommand = {
391
454
  }
392
455
  try {
393
456
  const result = await callMCPTool('session_delete', { sessionId });
457
+ const failure = toolError(result) ?? (result.deleted === false ? 'Session not found' : null);
458
+ if (failure) {
459
+ output.printError(`Failed to delete session: ${failure}`);
460
+ return { success: false, exitCode: 1 };
461
+ }
394
462
  output.writeln();
395
463
  output.printSuccess(`Session ${sessionId} deleted`);
396
464
  if (ctx.flags.format === 'json') {
@@ -412,7 +480,7 @@ const deleteCommand = {
412
480
  // Export subcommand
413
481
  const exportCommand = {
414
482
  name: 'export',
415
- description: 'Export session to file',
483
+ description: 'Export a saved session to a file. Usage: session export <session-id> [-o file] (or --latest)',
416
484
  options: [
417
485
  {
418
486
  name: 'output',
@@ -420,6 +488,12 @@ const exportCommand = {
420
488
  description: 'Output file path',
421
489
  type: 'string'
422
490
  },
491
+ {
492
+ name: 'latest',
493
+ description: 'Export the most recently saved session when no session id is given',
494
+ type: 'boolean',
495
+ default: false
496
+ },
423
497
  {
424
498
  name: 'format',
425
499
  short: 'f',
@@ -446,14 +520,23 @@ const exportCommand = {
446
520
  let outputPath = ctx.flags.output;
447
521
  const exportFormat = ctx.flags.format;
448
522
  const compress = ctx.flags.compress;
449
- // Get current session if no ID provided
523
+ // #3575: like `session delete`, refuse to guess which session to act on.
524
+ // Exporting the most recent one is available, but only when asked for.
450
525
  if (!sessionId) {
526
+ if (!ctx.flags.latest) {
527
+ output.printError('Session ID is required. Pass a session id, or --latest to export the most recently saved session.');
528
+ return { success: false, exitCode: 1 };
529
+ }
451
530
  try {
452
531
  const current = await callMCPTool('session_current', {});
532
+ if (toolError(current) || !current.sessionId) {
533
+ output.printError('No saved sessions to export.');
534
+ return { success: false, exitCode: 1 };
535
+ }
453
536
  sessionId = current.sessionId;
454
537
  }
455
538
  catch {
456
- output.printError('No active session. Provide a session ID to export.');
539
+ output.printError('No saved sessions to export.');
457
540
  return { success: false, exitCode: 1 };
458
541
  }
459
542
  }
@@ -469,6 +552,12 @@ const exportCommand = {
469
552
  sessionId,
470
553
  includeMemory: ctx.flags['include-memory'] !== false
471
554
  });
555
+ const failure = toolError(result);
556
+ if (failure || result.data === undefined) {
557
+ spinner.fail('Failed to export session');
558
+ output.printError(failure || 'The export returned no session data');
559
+ return { success: false, exitCode: 1 };
560
+ }
472
561
  // Format output
473
562
  let content;
474
563
  if (exportFormat === 'yaml') {
@@ -484,7 +573,8 @@ const exportCommand = {
484
573
  fs.writeFileSync(absolutePath, content, 'utf-8');
485
574
  spinner.succeed('Session exported');
486
575
  output.writeln();
487
- const exportStats = result.stats || {};
576
+ // session_export returns the whole record under `data`; its stats live there.
577
+ const exportStats = result.stats || result.data?.stats || {};
488
578
  output.printTable({
489
579
  columns: [
490
580
  { key: 'property', header: 'Property', width: 18 },
@@ -522,7 +612,7 @@ const exportCommand = {
522
612
  // Import subcommand
523
613
  const importCommand = {
524
614
  name: 'import',
525
- description: 'Import session from file',
615
+ description: 'Import a session from a file written by session export. Usage: session import <file> [--name n] [--activate]',
526
616
  options: [
527
617
  {
528
618
  name: 'name',
@@ -555,21 +645,32 @@ const importCommand = {
555
645
  const spinner = output.createSpinner({ text: 'Importing session...' });
556
646
  spinner.start();
557
647
  try {
558
- const content = fs.readFileSync(absolutePath, 'utf-8');
559
- let data;
560
- // Parse based on extension
648
+ // The YAML written by `session export --format yaml` is display-only;
649
+ // there is no YAML parser here, so say so instead of failing obscurely.
561
650
  if (absolutePath.endsWith('.yaml') || absolutePath.endsWith('.yml')) {
562
- // Simple YAML parsing (basic implementation)
563
- data = JSON.parse(content); // Would need proper YAML parser
564
- }
565
- else {
566
- data = JSON.parse(content);
651
+ try {
652
+ JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
653
+ }
654
+ catch {
655
+ spinner.fail('Failed to import session');
656
+ output.printError('YAML session import is not supported. Export with --format json and import that file.');
657
+ return { success: false, exitCode: 1 };
658
+ }
567
659
  }
660
+ // #3575: session_import reads the file itself from `inputPath`. Passing a
661
+ // parsed `data` object used to hit its required-argument check, and the
662
+ // CLI then printed "Session imported" before crashing on the error result.
568
663
  const result = await callMCPTool('session_import', {
569
- data,
664
+ inputPath: absolutePath,
570
665
  name: sessionName,
571
666
  activate
572
667
  });
668
+ const failure = toolError(result);
669
+ if (failure) {
670
+ spinner.fail('Failed to import session');
671
+ output.printError(failure);
672
+ return { success: false, exitCode: 1 };
673
+ }
573
674
  spinner.succeed('Session imported');
574
675
  output.writeln();
575
676
  output.printTable({
@@ -619,6 +720,11 @@ const currentCommand = {
619
720
  action: async (ctx) => {
620
721
  try {
621
722
  const result = await callMCPTool('session_current', { includeStats: true });
723
+ if (toolError(result)) {
724
+ output.printWarning('No active session');
725
+ output.printInfo('Save one with "claude-flow session save"');
726
+ return { success: true, data: { active: false } };
727
+ }
622
728
  if (ctx.flags.format === 'json') {
623
729
  output.printJson(result);
624
730
  return { success: true, data: result };
@@ -727,7 +833,8 @@ export const sessionCommand = {
727
833
  { command: 'claude-flow session save -n "checkpoint-1"', description: 'Save current session' },
728
834
  { command: 'claude-flow session restore session-123', description: 'Restore a session' },
729
835
  { command: 'claude-flow session delete session-123', description: 'Delete a session' },
730
- { command: 'claude-flow session export -o backup.json', description: 'Export session to file' },
836
+ { command: 'claude-flow session export session-123 -o backup.json', description: 'Export a session to file' },
837
+ { command: 'claude-flow session export --latest -o backup.json', description: 'Export the most recently saved session' },
731
838
  { command: 'claude-flow session import backup.json', description: 'Import session from file' },
732
839
  { command: 'claude-flow session current', description: 'Show current session' }
733
840
  ],
@@ -196,17 +196,14 @@ function getSwarmStatus(swarmId) {
196
196
  // Ignore
197
197
  }
198
198
  }
199
- // Calculate dynamic progress based on actual state
200
- // If no swarm state, show 0%. Otherwise calculate from completed tasks
199
+ // Progress is completed / total tasks. With no tasks there is nothing to be
200
+ // a fraction of, so it is `null` with a reason — never an invented number
201
+ // (#3572: a hard-coded 5% was shown for a swarm with no tasks, forever).
201
202
  const totalTasks = completedTasks + inProgressTasks + pendingTasks;
202
- let progress = 0;
203
- if (totalTasks > 0) {
204
- progress = Math.round((completedTasks / totalTasks) * 100);
205
- }
206
- else if (swarmState) {
207
- // Swarm initialized but no tasks yet
208
- progress = 5;
209
- }
203
+ const progress = totalTasks > 0
204
+ ? Math.round((completedTasks / totalTasks) * 100)
205
+ : null;
206
+ const progressReason = totalTasks > 0 ? null : 'no tasks';
210
207
  // Determine status
211
208
  let status = 'idle';
212
209
  if (inProgressTasks > 0 || activeAgents > 0) {
@@ -243,6 +240,7 @@ function getSwarmStatus(swarmId) {
243
240
  completed: 0
244
241
  },
245
242
  progress,
243
+ progressReason,
246
244
  tasks: {
247
245
  total: totalTasks,
248
246
  completed: completedTasks,
@@ -738,7 +736,9 @@ const statusCommand = {
738
736
  output.writeln(output.bold(`Swarm Status: ${status.id ?? output.dim('unknown id')}`));
739
737
  output.writeln();
740
738
  // Progress bar
741
- output.writeln(`Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
739
+ output.writeln(status.progress === null
740
+ ? `Overall Progress: ${output.dim(`n/a (${status.progressReason})`)}`
741
+ : `Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
742
742
  output.writeln();
743
743
  // Agent status
744
744
  output.writeln(output.bold('Agents'));
@@ -155,10 +155,19 @@ export class CLI {
155
155
  // loudly (not silent) — the existing project helpers were left intact.
156
156
  this.output.printWarning(`Skipped helper auto-refresh — ${r.blocked}. Reinstall @claude-flow/cli from a trusted source.`);
157
157
  }
158
+ else if (r.healed) {
159
+ // #3565: a critical helper's stamp matched but its on-disk content
160
+ // didn't hash-match the signed manifest — restored, but always
161
+ // surfaced (not verbose-gated like a routine version-bump refresh).
162
+ this.output.printWarning(`Detected tampered critical helper(s) in .claude/helpers (${(r.tampered || []).join(', ')}) — restored verified content from the installed package. If this wasn't expected, find out what modified them.`);
163
+ }
158
164
  else if (r.refreshed && this.output.isVerbose()) {
159
165
  this.output.printDebug(`Refreshed .claude/helpers (${r.from} → ${r.to})`);
160
166
  }
161
- if (r.global?.refreshed && this.output.isVerbose()) {
167
+ if (r.global?.healed) {
168
+ this.output.printWarning(`Detected tampered critical helper(s) in ~/.claude/helpers (${(r.global.tampered || []).join(', ')}) — restored verified content.`);
169
+ }
170
+ else if (r.global?.refreshed && this.output.isVerbose()) {
162
171
  this.output.printDebug(`Refreshed ~/.claude/helpers (${r.global.from} → ${r.global.to})`);
163
172
  }
164
173
  else if (r.global?.blocked && r.global.blocked !== r.blocked) {
@@ -18,6 +18,7 @@ import { generatePreCommitHook, generatePostCommitHook, generateSessionManager,
18
18
  import { getInstalledCliVersion, HELPERS_STAMP_FILE } from './helper-refresh.js';
19
19
  import { generateClaudeMd } from './claudemd-generator.js';
20
20
  import { recordMemoryPackagePath } from './memory-package-resolver.js';
21
+ import { ensureCommonJsCompanions } from './helper-companions.js';
21
22
  import { scanSettingsForRisk, formatRiskFindingsAsWarnings } from './settings-risk-scanner.js';
22
23
  /**
23
24
  * Skills to copy based on configuration
@@ -499,7 +500,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
499
500
  const sourceHelpersForUpgrade = findSourceHelpersDir();
500
501
  if (sourceHelpersForUpgrade) {
501
502
  // Keep in sync with helper-refresh.ts:CRITICAL_HELPERS.
502
- const criticalHelpers = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs', 'router.js'];
503
+ const criticalHelpers = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs', 'router.cjs'];
503
504
  for (const helperName of criticalHelpers) {
504
505
  const targetPath = path.join(targetDir, '.claude', 'helpers', helperName);
505
506
  const sourcePath = path.join(sourceHelpersForUpgrade, helperName);
@@ -524,7 +525,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
524
525
  'hook-handler.cjs': generateHookHandler(),
525
526
  'intelligence.cjs': generateIntelligenceStub(),
526
527
  'auto-memory-hook.mjs': generateAutoMemoryHook(),
527
- 'router.js': generateAgentRouter(), // ADR-389
528
+ 'router.cjs': generateAgentRouter(), // ADR-389 / #3555
528
529
  };
529
530
  for (const [helperName, content] of Object.entries(generatedCritical)) {
530
531
  const targetPath = path.join(targetDir, '.claude', 'helpers', helperName);
@@ -541,6 +542,11 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
541
542
  catch { }
542
543
  }
543
544
  }
545
+ // #3555: the refreshed hook-handler requires the .cjs companions; older
546
+ // installs only have session.js / memory.js, which can't load in ESM.
547
+ for (const name of await ensureCommonJsCompanions(path.join(targetDir, '.claude', 'helpers'))) {
548
+ result.created.push(`.claude/helpers/${name}`);
549
+ }
544
550
  // Stamp the installed version so the startup auto-refresh treats these as
545
551
  // current (no redundant re-copy on the next command).
546
552
  try {
@@ -1295,9 +1301,9 @@ async function writeHelpers(targetDir, options, result) {
1295
1301
  const helpers = {
1296
1302
  'pre-commit': generatePreCommitHook(),
1297
1303
  'post-commit': generatePostCommitHook(),
1298
- 'session.js': generateSessionManager(),
1299
- 'router.js': generateAgentRouter(),
1300
- 'memory.js': generateMemoryHelper(),
1304
+ 'session.cjs': generateSessionManager(),
1305
+ 'router.cjs': generateAgentRouter(),
1306
+ 'memory.cjs': generateMemoryHelper(),
1301
1307
  'hook-handler.cjs': generateHookHandler(),
1302
1308
  'intelligence.cjs': generateIntelligenceStub(),
1303
1309
  'auto-memory-hook.mjs': generateAutoMemoryHook(),
@@ -0,0 +1,3 @@
1
+ export declare const COMMONJS_COMPANION_HELPERS: readonly ["session.cjs", "memory.cjs"];
2
+ export declare function ensureCommonJsCompanions(helpersDir: string): Promise<string[]>;
3
+ //# sourceMappingURL=helper-companions.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * CommonJS companion helpers that `hook-handler.cjs` requires but that are not
3
+ * in the signed critical set (#3555).
4
+ *
5
+ * They are CommonJS, so they ship as `.cjs`: a `.js` copy cannot load in a
6
+ * `"type":"module"` project. Installs made before the rename have only the
7
+ * `.js` copies, which the refreshed hook-handler no longer requires, so the
8
+ * refresh writes any missing `.cjs` companion from the CLI's own compiled
9
+ * generators (the trust root; no external file to verify). Stale `.js` copies
10
+ * are left in place and ignored. Existing `.cjs` files are never overwritten.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ export const COMMONJS_COMPANION_HELPERS = ['session.cjs', 'memory.cjs'];
15
+ export async function ensureCommonJsCompanions(helpersDir) {
16
+ const missing = COMMONJS_COMPANION_HELPERS.filter((name) => !fs.existsSync(path.join(helpersDir, name)));
17
+ if (missing.length === 0)
18
+ return [];
19
+ const gen = await import('./helpers-generator.js');
20
+ const content = {
21
+ 'session.cjs': gen.generateSessionManager,
22
+ 'memory.cjs': gen.generateMemoryHelper,
23
+ };
24
+ const written = [];
25
+ for (const name of missing) {
26
+ try {
27
+ fs.writeFileSync(path.join(helpersDir, name), content[name](), { encoding: 'utf-8', mode: 0o755 });
28
+ written.push(name);
29
+ }
30
+ catch { /* best-effort: hook-handler degrades gracefully without it */ }
31
+ }
32
+ return written;
33
+ }
34
+ //# sourceMappingURL=helper-companions.js.map
@@ -0,0 +1,21 @@
1
+ export interface IntegrityResult {
2
+ /** Critical helper names whose on-disk hash doesn't match the signed manifest
3
+ * (or that are missing from disk despite being part of the signed set). */
4
+ tampered: string[];
5
+ /** Set only when the package's OWN signed manifest can't be verified at all —
6
+ * there's no ground truth to check the installed files against, distinct
7
+ * from a clean `tampered: []` result. */
8
+ blocked?: string;
9
+ }
10
+ /**
11
+ * Re-verify each of `criticalHelpers` present on disk in `helpersDir` against
12
+ * `sourceDir`'s signed manifest. Read-only — safe to call without holding any
13
+ * refresh lock; callers only need to acquire one if `tampered` comes back
14
+ * non-empty and they intend to repair it.
15
+ *
16
+ * `sourceDir: null` (package source unresolvable) returns `{ tampered: [] }`
17
+ * — fails open rather than flagging every install as tampered when there is
18
+ * no ground truth at all to compare against.
19
+ */
20
+ export declare function verifyInstalledCriticalHelpers(helpersDir: string, sourceDir: string | null, criticalHelpers: readonly string[], pubkeyPemOverride?: string): IntegrityResult;
21
+ //# sourceMappingURL=helper-integrity.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Post-install integrity re-verification for signed critical helpers (#3565).
3
+ *
4
+ * `helper-refresh.ts` verifies a helper's signature/hash once, at copy time,
5
+ * then stamps the target directory with the installed CLI version. Every
6
+ * later command short-circuited on "stamp already matches" with NO further
7
+ * verification — so a critical helper modified on disk after that point (a
8
+ * sibling package's postinstall, a stray older CLI process racing a write,
9
+ * direct tampering) stayed silently modified through any number of
10
+ * subsequent commands, forever. This module re-hashes the already-INSTALLED
11
+ * helpers against the signed manifest independent of the stamp, so callers
12
+ * can re-run it on every invocation rather than trusting a single past check.
13
+ *
14
+ * Deliberately has no opinion on WHAT to do about tampering (re-copy, warn,
15
+ * refuse) — that policy lives in helper-refresh.ts, which already owns the
16
+ * fail-closed verify-then-copy path this result feeds into to heal.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { verifyHelpersManifest, sha256Hex, HELPERS_MANIFEST_FILE, } from './helper-signing.js';
21
+ /**
22
+ * Re-verify each of `criticalHelpers` present on disk in `helpersDir` against
23
+ * `sourceDir`'s signed manifest. Read-only — safe to call without holding any
24
+ * refresh lock; callers only need to acquire one if `tampered` comes back
25
+ * non-empty and they intend to repair it.
26
+ *
27
+ * `sourceDir: null` (package source unresolvable) returns `{ tampered: [] }`
28
+ * — fails open rather than flagging every install as tampered when there is
29
+ * no ground truth at all to compare against.
30
+ */
31
+ export function verifyInstalledCriticalHelpers(helpersDir, sourceDir, criticalHelpers, pubkeyPemOverride) {
32
+ if (!sourceDir)
33
+ return { tampered: [] };
34
+ let trusted = null;
35
+ try {
36
+ trusted = verifyHelpersManifest(fs.readFileSync(path.join(sourceDir, HELPERS_MANIFEST_FILE), 'utf-8'), pubkeyPemOverride);
37
+ }
38
+ catch {
39
+ trusted = null;
40
+ }
41
+ if (!trusted)
42
+ return { tampered: [], blocked: 'signed helpers manifest missing or signature invalid' };
43
+ const tampered = [];
44
+ for (const name of criticalHelpers) {
45
+ const expected = trusted.files[name];
46
+ // Not part of THIS version's signed set (or source doesn't ship it) —
47
+ // nothing to compare the installed copy against.
48
+ if (!expected || !fs.existsSync(path.join(sourceDir, name)))
49
+ continue;
50
+ let actual = null;
51
+ try {
52
+ actual = sha256Hex(fs.readFileSync(path.join(helpersDir, name)));
53
+ }
54
+ catch {
55
+ actual = null;
56
+ }
57
+ if (actual !== expected)
58
+ tampered.push(name);
59
+ }
60
+ return { tampered };
61
+ }
62
+ //# sourceMappingURL=helper-integrity.js.map
@@ -10,6 +10,20 @@ interface RefreshOptions {
10
10
  lockRetryMsOverride?: number;
11
11
  malformedLockStaleMsOverride?: number;
12
12
  }
13
+ interface RefreshResult {
14
+ refreshed: boolean;
15
+ from?: string;
16
+ to?: string;
17
+ blocked?: string;
18
+ /** Set when `refreshed` was caused by #3565 healing, not a version bump —
19
+ * the stamp already matched but one or more critical helpers failed
20
+ * on-disk integrity verification and were re-copied from the verified
21
+ * source. */
22
+ healed?: boolean;
23
+ /** Critical helper names that failed integrity verification, present only
24
+ * alongside `healed: true`. */
25
+ tampered?: string[];
26
+ }
13
27
  /**
14
28
  * ruflo-owned helpers that carry hook logic (or the render surface for the
15
29
  * funnel disclosure row) and must track the package version. Adding to this
@@ -19,6 +33,8 @@ interface RefreshOptions {
19
33
  export declare const CRITICAL_HELPERS: string[];
20
34
  /** Installed @claude-flow/cli version — the value the helpers are stamped with. */
21
35
  export declare function getInstalledCliVersion(): string;
36
+ /** Locate the in-package `.claude/helpers` dir (the copy source). Null if not found. */
37
+ export declare function findPackageHelpersDir(): string | null;
22
38
  /**
23
39
  * On CLI startup, refresh critical helpers if their stamp is older than the
24
40
  * installed CLI version. Two passes:
@@ -70,17 +86,8 @@ export declare function getInstalledCliVersion(): string;
70
86
  * compat with pre-3.31.3 callers). If the global pass ran, its own result is
71
87
  * carried in the optional `global` field.
72
88
  */
73
- export declare function autoRefreshHelpersIfStale(cwd: string, opts?: RefreshOptions): Promise<{
74
- refreshed: boolean;
75
- from?: string;
76
- to?: string;
77
- blocked?: string;
78
- global?: {
79
- refreshed: boolean;
80
- from?: string;
81
- to?: string;
82
- blocked?: string;
83
- };
89
+ export declare function autoRefreshHelpersIfStale(cwd: string, opts?: RefreshOptions): Promise<RefreshResult & {
90
+ global?: RefreshResult;
84
91
  }>;
85
92
  export {};
86
93
  //# sourceMappingURL=helper-refresh.d.ts.map