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
@@ -69,17 +69,126 @@ function listSessions() {
69
69
  }
70
70
  return sessions;
71
71
  }
72
- // Load related stores for session data
73
- function loadRelatedStores(options) {
74
- const data = {};
75
- if (options.includeMemory) {
72
+ /** Snapshot format written by session_save since #3573. */
73
+ const MEMORY_SNAPSHOT_FORMAT = 'ruflo-session-memory/2';
74
+ const MEMORY_PAGE_SIZE = 500;
75
+ async function readLiveEntries(listEntries, dbPath, encryptWrites) {
76
+ const rows = [];
77
+ // Bounded: stop on a short page, once `total` rows are in hand, or after a
78
+ // hard cap, so a backend that ignores `offset` cannot loop forever.
79
+ for (let page = 0, offset = 0; page < 10_000; page++, offset += MEMORY_PAGE_SIZE) {
80
+ const result = await listEntries({
81
+ dbPath, includeContent: true, limit: MEMORY_PAGE_SIZE, offset,
82
+ ...(encryptWrites === undefined ? {} : { encryptWrites }),
83
+ });
84
+ if (!result.success)
85
+ throw new Error(result.error || `could not list ${dbPath}`);
86
+ rows.push(...result.entries);
87
+ if (result.entries.length < MEMORY_PAGE_SIZE || rows.length >= result.total)
88
+ break;
89
+ }
90
+ return rows;
91
+ }
92
+ /**
93
+ * #3573: capture the memory the user actually has.
94
+ *
95
+ * Before this, session_save read only the pre-SQLite `.claude-flow/memory/store.json`,
96
+ * which a current install never writes, so `--include-memory` saved nothing and
97
+ * reported "Memory Entries: 0". The snapshot now covers the same store `memory
98
+ * list` reads (resolveDbPath), plus rows that exist only in the sibling AgentDB
99
+ * store written by the MCP path, de-duplicated by namespace+key (default CLI
100
+ * writes are mirrored into AgentDB, so most rows are in both). A legacy
101
+ * store.json is still captured for backward compatibility.
102
+ */
103
+ async function captureMemorySnapshot() {
104
+ const report = {
105
+ requested: true, status: 'captured', entries: 0,
106
+ sources: { memoryDb: 0, agentdb: 0, legacyJson: 0 },
107
+ };
108
+ const entries = {};
109
+ let legacy;
110
+ const legacyPath = join(getProjectCwd(), STORAGE_DIR, 'memory', 'store.json');
111
+ if (existsSync(legacyPath)) {
76
112
  try {
77
- const memoryPath = join(getProjectCwd(), STORAGE_DIR, 'memory', 'store.json');
78
- if (existsSync(memoryPath)) {
79
- data.memory = JSON.parse(readFileSync(memoryPath, 'utf-8'));
113
+ legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
114
+ const legacyEntries = legacy.entries || {};
115
+ for (const [id, entry] of Object.entries(legacyEntries)) {
116
+ entries[id] = entry;
117
+ report.sources.legacyJson++;
80
118
  }
81
119
  }
82
- catch { /* ignore */ }
120
+ catch { /* unreadable legacy file is not memory we can restore */ }
121
+ }
122
+ let sawStore = legacy !== undefined;
123
+ try {
124
+ const { listEntries, resolveDbPath } = await import('../memory/memory-initializer.js');
125
+ const primary = resolveDbPath();
126
+ const seen = new Set();
127
+ if (existsSync(primary)) {
128
+ sawStore = true;
129
+ for (const row of await readLiveEntries(listEntries, primary)) {
130
+ const id = `${row.namespace}::${row.key}`;
131
+ seen.add(id);
132
+ entries[id] = {
133
+ key: row.key, value: row.content ?? '', namespace: row.namespace,
134
+ provenanceType: row.provenanceType, source: 'memory-db',
135
+ };
136
+ report.sources.memoryDb++;
137
+ }
138
+ }
139
+ let sibling = null;
140
+ try {
141
+ const { siblingAgentDbPath } = await import('../memory/memory-bridge.js');
142
+ sibling = siblingAgentDbPath(primary);
143
+ }
144
+ catch { /* no bridge, no sibling store */ }
145
+ if (sibling && existsSync(sibling)) {
146
+ sawStore = true;
147
+ // The AgentDB store is read by native SQLite and must stay plaintext.
148
+ for (const row of await readLiveEntries(listEntries, sibling, false)) {
149
+ const id = `${row.namespace}::${row.key}`;
150
+ if (seen.has(id))
151
+ continue;
152
+ seen.add(id);
153
+ entries[id] = {
154
+ key: row.key, value: row.content ?? '', namespace: row.namespace,
155
+ provenanceType: row.provenanceType, source: 'agentdb',
156
+ };
157
+ report.sources.agentdb++;
158
+ }
159
+ }
160
+ }
161
+ catch (e) {
162
+ report.status = 'error';
163
+ report.error = e.message;
164
+ }
165
+ report.entries = Object.keys(entries).length;
166
+ if (report.status !== 'error' && !sawStore)
167
+ report.status = 'no-store';
168
+ if (report.entries === 0 && legacy === undefined) {
169
+ return { report };
170
+ }
171
+ return {
172
+ memory: {
173
+ format: MEMORY_SNAPSHOT_FORMAT,
174
+ entries,
175
+ ...(legacy !== undefined ? { legacy } : {}),
176
+ },
177
+ report,
178
+ };
179
+ }
180
+ // Load related stores for session data
181
+ async function loadRelatedStores(options) {
182
+ const data = {};
183
+ let memoryCapture = {
184
+ requested: false, status: 'not-requested', entries: 0,
185
+ sources: { memoryDb: 0, agentdb: 0, legacyJson: 0 },
186
+ };
187
+ if (options.includeMemory) {
188
+ const captured = await captureMemorySnapshot();
189
+ memoryCapture = captured.report;
190
+ if (captured.memory)
191
+ data.memory = captured.memory;
83
192
  }
84
193
  if (options.includeTasks) {
85
194
  try {
@@ -99,7 +208,92 @@ function loadRelatedStores(options) {
99
208
  }
100
209
  catch { /* ignore */ }
101
210
  }
102
- return data;
211
+ return { data, memoryCapture };
212
+ }
213
+ /** Count entries in a memory snapshot of either format. */
214
+ function countMemoryEntries(memory) {
215
+ if (!memory || typeof memory !== 'object')
216
+ return 0;
217
+ return Object.keys(memory.entries || {}).length;
218
+ }
219
+ /**
220
+ * Restore a memory snapshot into the live store and report what was actually
221
+ * written. #3573: the previous restore swallowed every write failure and then
222
+ * echoed the count recorded at save time.
223
+ */
224
+ async function restoreMemorySnapshot(memory) {
225
+ const outcome = { restored: 0, failed: 0, errors: [] };
226
+ const isV2 = memory.format === MEMORY_SNAPSHOT_FORMAT;
227
+ // Legacy snapshots are the old store.json itself; v2 snapshots carry it
228
+ // under `legacy` only when one existed at save time.
229
+ const legacyJson = isV2 ? memory.legacy : memory;
230
+ if (legacyJson !== undefined) {
231
+ const memoryDir = join(getProjectCwd(), STORAGE_DIR, 'memory');
232
+ if (!existsSync(memoryDir))
233
+ mkdirRestricted(memoryDir);
234
+ writeFileRestricted(join(memoryDir, 'store.json'), JSON.stringify(legacyJson, null, 2));
235
+ }
236
+ const entries = memory.entries;
237
+ if (!entries)
238
+ return outcome;
239
+ let storeEntry;
240
+ let isValidProvenanceType = () => false;
241
+ try {
242
+ const mod = await import('../memory/memory-initializer.js');
243
+ storeEntry = mod.storeEntry;
244
+ try {
245
+ if (typeof mod.isValidProvenanceType === 'function')
246
+ isValidProvenanceType = mod.isValidProvenanceType;
247
+ }
248
+ catch { /* optional: without it, provenance is simply not carried over */ }
249
+ }
250
+ catch (e) {
251
+ outcome.failed = Object.keys(entries).length;
252
+ outcome.errors.push(`memory store unavailable: ${e.message}`);
253
+ return outcome;
254
+ }
255
+ for (const entry of Object.values(entries)) {
256
+ const key = entry.key || entry.id || '';
257
+ const value = entry.value || entry.content || '';
258
+ if (!key || !value) {
259
+ outcome.failed++;
260
+ continue;
261
+ }
262
+ try {
263
+ // A snapshot from another version may carry a provenance value this one
264
+ // rejects; drop it rather than fail an otherwise-good row.
265
+ const provenance = entry.provenanceType && entry.provenanceType !== 'unknown'
266
+ && isValidProvenanceType(entry.provenanceType) ? entry.provenanceType : undefined;
267
+ const result = await storeEntry({
268
+ key,
269
+ value,
270
+ namespace: entry.namespace || 'restored',
271
+ upsert: true,
272
+ ...(provenance ? { provenanceType: provenance } : {}),
273
+ });
274
+ if (result && result.success === false) {
275
+ outcome.failed++;
276
+ if (result.error && outcome.errors.length < 5)
277
+ outcome.errors.push(`${key}: ${result.error}`);
278
+ }
279
+ else {
280
+ outcome.restored++;
281
+ }
282
+ }
283
+ catch (e) {
284
+ outcome.failed++;
285
+ if (outcome.errors.length < 5)
286
+ outcome.errors.push(`${key}: ${e.message}`);
287
+ }
288
+ }
289
+ return outcome;
290
+ }
291
+ /** A session record is an object that carries at least one of its defining fields. */
292
+ function isSessionRecordLike(value) {
293
+ if (!value || typeof value !== 'object' || Array.isArray(value))
294
+ return false;
295
+ const v = value;
296
+ return 'data' in v || 'stats' in v || 'sessionId' in v || 'savedAt' in v;
103
297
  }
104
298
  export const sessionTools = [
105
299
  {
@@ -129,7 +323,7 @@ export const sessionTools = [
129
323
  }
130
324
  const sessionId = `session-${Date.now()}-${randomUUID().slice(0, 8)}`;
131
325
  // Load related data based on options
132
- const data = loadRelatedStores({
326
+ const { data, memoryCapture } = await loadRelatedStores({
133
327
  includeMemory: input.includeMemory,
134
328
  includeTasks: input.includeTasks,
135
329
  includeAgents: input.includeAgents,
@@ -138,7 +332,7 @@ export const sessionTools = [
138
332
  const stats = {
139
333
  tasks: data.tasks ? Object.keys(data.tasks.tasks || {}).length : 0,
140
334
  agents: data.agents ? Object.keys(data.agents.agents || {}).length : 0,
141
- memoryEntries: data.memory ? Object.keys(data.memory.entries || {}).length : 0,
335
+ memoryEntries: countMemoryEntries(data.memory),
142
336
  totalSize: 0,
143
337
  };
144
338
  const session = {
@@ -158,6 +352,7 @@ export const sessionTools = [
158
352
  name: session.name,
159
353
  savedAt: session.savedAt,
160
354
  stats: session.stats,
355
+ memoryCapture,
161
356
  path: getSessionPath(sessionId),
162
357
  };
163
358
  },
@@ -207,35 +402,11 @@ export const sessionTools = [
207
402
  }
208
403
  }
209
404
  if (session) {
210
- // Restore data to respective stores (legacy JSON for backward compat).
211
- // audit_1776853149979: tighten perms on the restored stores too.
405
+ // Restore data to respective stores. audit_1776853149979: tighten
406
+ // perms on the restored stores too.
407
+ let memoryRestore;
212
408
  if (input.restoreMemory !== false && session.data?.memory) {
213
- const memoryDir = join(getProjectCwd(), STORAGE_DIR, 'memory');
214
- if (!existsSync(memoryDir))
215
- mkdirRestricted(memoryDir);
216
- writeFileRestricted(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2));
217
- // Also populate active sql.js SQLite database so memory-tools can find entries
218
- try {
219
- const { storeEntry } = await import('../memory/memory-initializer.js');
220
- const memoryData = session.data.memory;
221
- if (memoryData.entries) {
222
- for (const entry of Object.values(memoryData.entries)) {
223
- const key = entry.key || entry.id || '';
224
- const value = entry.value || entry.content || '';
225
- if (key && value) {
226
- await storeEntry({
227
- key,
228
- value,
229
- namespace: entry.namespace || 'restored',
230
- upsert: true,
231
- });
232
- }
233
- }
234
- }
235
- }
236
- catch {
237
- // Legacy JSON restore is the fallback -- sql.js import may not be available
238
- }
409
+ memoryRestore = await restoreMemorySnapshot(session.data.memory);
239
410
  }
240
411
  if (input.restoreTasks !== false && session.data?.tasks) {
241
412
  const taskDir = join(getProjectCwd(), STORAGE_DIR, 'tasks');
@@ -260,6 +431,11 @@ export const sessionTools = [
260
431
  },
261
432
  restoredAt: new Date().toISOString(),
262
433
  stats: session.stats,
434
+ // #3573: counts actually written, not the count recorded at save time.
435
+ ...(memoryRestore ? {
436
+ memoryRestore,
437
+ stats: { ...session.stats, memoryEntriesRestored: memoryRestore.restored },
438
+ } : {}),
263
439
  };
264
440
  }
265
441
  return {
@@ -484,30 +660,40 @@ export const sessionTools = [
484
660
  // #1916: `ruflo session import <file>` referenced an unregistered
485
661
  // `session_import` tool. Reads a session JSON and re-saves it locally.
486
662
  name: 'session_import',
487
- description: 'Import a session JSON file (produced by session_export) into the local session store and optionally activate it. Use when native Read is wrong because the file is a structured session record that must be re-registered (new id, stats recomputed) rather than just read. For reading the file, native Read is fine. Pair with session_export on the source.',
663
+ description: 'Import a session (produced by session_export) into the local session store and optionally activate it. Pass either inputPath (a session JSON file) or data (the session record itself). Use when native Read is wrong because the file is a structured session record that must be re-registered (new id, stats recomputed) rather than just read. For reading the file, native Read is fine. Pair with session_export on the source.',
488
664
  category: 'session',
489
665
  inputSchema: {
490
666
  type: 'object',
491
667
  properties: {
492
- inputPath: { type: 'string', description: 'Path to the session JSON file to import' },
668
+ inputPath: { type: 'string', description: 'Path to the session JSON file to import (or pass data)' },
669
+ data: { type: 'object', description: 'The session record itself, as written by session_export (or pass inputPath)' },
493
670
  name: { type: 'string', description: 'Override the imported session name' },
494
671
  activate: { type: 'boolean', description: 'Restore the imported session into the active stores' },
495
672
  },
496
- required: ['inputPath'],
497
673
  },
498
674
  handler: async (input) => {
499
- const inputPath = String(input.inputPath ?? '');
500
- if (!inputPath || !existsSync(inputPath))
501
- return { error: `File not found: ${inputPath || '(empty)'}` };
502
675
  let parsed;
503
- try {
504
- parsed = JSON.parse(readFileSync(inputPath, 'utf-8'));
676
+ if (input.data !== undefined && input.data !== null) {
677
+ parsed = input.data;
678
+ }
679
+ else {
680
+ const inputPath = String(input.inputPath ?? '');
681
+ if (!inputPath)
682
+ return { error: 'Provide inputPath (a session JSON file) or data (a session record)' };
683
+ if (!existsSync(inputPath))
684
+ return { error: `File not found: ${inputPath}` };
685
+ try {
686
+ parsed = JSON.parse(readFileSync(inputPath, 'utf-8'));
687
+ }
688
+ catch (e) {
689
+ return { error: `Invalid session JSON: ${e.message}` };
690
+ }
505
691
  }
506
- catch (e) {
507
- return { error: `Invalid session JSON: ${e.message}` };
692
+ if (!isSessionRecordLike(parsed)) {
693
+ return { error: 'Not a session record: expected an object produced by session export' };
508
694
  }
509
695
  const newId = `session-${Date.now()}-${randomUUID().slice(0, 8)}`;
510
- const stats = parsed.stats || { tasks: 0, agents: 0, memoryEntries: 0, totalSize: 0 };
696
+ const stats = { tasks: 0, agents: 0, memoryEntries: 0, totalSize: 0, ...(parsed.stats || {}) };
511
697
  const session = {
512
698
  sessionId: newId,
513
699
  name: input.name ? String(input.name) : (parsed.name || 'imported-session'),
@@ -15,6 +15,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
15
15
  import { createRequire } from 'node:module';
16
16
  import { readFileMaybeEncrypted, writeFileAtomic, writeFileRestricted } from '../fs-secure.js';
17
17
  import { restoreMemoryDbFromBackup } from '../services/memory-backup.js';
18
+ import { validateIdentifier } from '../mcp-tools/validate-input.js';
18
19
  /**
19
20
  * ADR-323 — typed memory provenance. Distinguishes WHO/WHAT wrote a memory
20
21
  * entry (a user's stated claim vs an agent's own output vs a tool result vs
@@ -3392,11 +3393,13 @@ export async function withMemoryDbLock(dbPath, fn) {
3392
3393
  }
3393
3394
  }
3394
3395
  }
3395
- const NAMESPACE_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
3396
3396
  export async function purgeNamespace(options) {
3397
3397
  const { namespace, dbPath: customPath } = options;
3398
- if (!NAMESPACE_PATTERN.test(namespace)) {
3399
- return { success: false, deletedCount: 0, remainingEntries: 0, error: `Invalid namespace: ${namespace}` };
3398
+ // #3570: the same validator store, import and export use, so any namespace
3399
+ // that can be written can also be purged (`team:alice` included).
3400
+ const vNs = validateIdentifier(namespace, 'namespace');
3401
+ if (!vNs.valid) {
3402
+ return { success: false, deletedCount: 0, remainingEntries: 0, error: `Invalid namespace: ${vNs.error}` };
3400
3403
  }
3401
3404
  const swarmDir = getMemoryRoot();
3402
3405
  const dbPath = customPath ? path.resolve(customPath) : path.join(swarmDir, 'memory.db');
@@ -3,6 +3,7 @@
3
3
  * Handles actual plugin installation, persistence, and lifecycle
4
4
  * Bridges discovery service with file system persistence
5
5
  */
6
+ import { type TrustDecision } from './trust-policy.js';
6
7
  export interface InstalledPlugin {
7
8
  name: string;
8
9
  version: string;
@@ -13,6 +14,40 @@ export interface InstalledPlugin {
13
14
  commands?: string[];
14
15
  hooks?: string[];
15
16
  config?: Record<string, unknown>;
17
+ /** Declared (or registry-assigned) trust level, recorded at install time. */
18
+ trustLevel?: string;
19
+ /** Declared permissions, recorded at install time so they can be enforced. */
20
+ permissions?: string[];
21
+ /** How the install was verified. */
22
+ verification?: 'checksum' | 'npm-integrity' | 'policy' | 'skipped';
23
+ /** Whether npm lifecycle scripts ran during install (false = `--ignore-scripts`). */
24
+ scriptsRun?: boolean;
25
+ /** Hooks/commands the plugin declared but that were not registered, and why. */
26
+ withheld?: {
27
+ hooks: string[];
28
+ commands: string[];
29
+ reasons: string[];
30
+ };
31
+ }
32
+ /** Options for {@link PluginManager.installFromLocal} / {@link PluginManager.installFromNpm}. */
33
+ export interface PluginInstallOptions {
34
+ /** `--verify` (default true). */
35
+ verify?: boolean;
36
+ /** `--trust` (default false). */
37
+ trust?: boolean;
38
+ /** Registry checksum (`sha256:<hex>`) the downloaded tarball must match. */
39
+ expectedChecksum?: string;
40
+ /** Trust level from the registry entry, when the plugin was found there. */
41
+ registryTrustLevel?: string;
42
+ /** Permissions from the registry entry, merged with the package's own declaration. */
43
+ registryPermissions?: string[];
44
+ }
45
+ export interface PluginInstallResult {
46
+ success: boolean;
47
+ error?: string;
48
+ plugin?: InstalledPlugin;
49
+ decision?: TrustDecision;
50
+ warnings?: string[];
16
51
  }
17
52
  export interface InstalledPluginsManifest {
18
53
  version: '1.0.0';
@@ -46,19 +81,11 @@ export declare class PluginManager {
46
81
  /**
47
82
  * Install a plugin from npm
48
83
  */
49
- installFromNpm(packageName: string, version?: string): Promise<{
50
- success: boolean;
51
- error?: string;
52
- plugin?: InstalledPlugin;
53
- }>;
84
+ installFromNpm(packageName: string, version?: string, opts?: PluginInstallOptions): Promise<PluginInstallResult>;
54
85
  /**
55
86
  * Install a plugin from a local path
56
87
  */
57
- installFromLocal(sourcePath: string): Promise<{
58
- success: boolean;
59
- error?: string;
60
- plugin?: InstalledPlugin;
61
- }>;
88
+ installFromLocal(sourcePath: string, opts?: PluginInstallOptions): Promise<PluginInstallResult>;
62
89
  /**
63
90
  * Uninstall a plugin
64
91
  */
@@ -4,9 +4,11 @@
4
4
  * Bridges discovery service with file system persistence
5
5
  */
6
6
  import * as fs from 'fs';
7
+ import * as os from 'os';
7
8
  import * as path from 'path';
8
9
  import { execFile } from 'child_process';
9
10
  import { promisify } from 'util';
11
+ import { evaluatePluginTrust, parseSha256Checksum, readDeclaredTrust, sha256Hex, shouldRunInstallScripts, } from './trust-policy.js';
10
12
  const execFileAsync = promisify(execFile);
11
13
  // On Windows, `npm` is a shell script (no `.exe`) and `npm.cmd` is a batch
12
14
  // wrapper. Since Node 18.20.2 / 20.12.2 (CVE-2024-27980) the runtime refuses
@@ -30,6 +32,30 @@ function validatePackageName(spec) {
30
32
  throw new Error(`Invalid package name: ${spec}`);
31
33
  }
32
34
  }
35
+ /**
36
+ * Apply the #3557 trust policy to a plugin's package.json: record its declared
37
+ * trust and permissions, and withhold hooks/commands the policy doesn't allow.
38
+ */
39
+ function applyTrustPolicy(pkg, opts) {
40
+ const block = (pkg['claude-flow'] ?? {});
41
+ const commands = Array.isArray(block.commands) ? block.commands : [];
42
+ const hooks = Array.isArray(block.hooks) ? block.hooks : [];
43
+ const declared = readDeclaredTrust(pkg);
44
+ const permissions = [...new Set([...(opts.registryPermissions ?? []), ...declared.permissions])];
45
+ const trustLevel = opts.registryTrustLevel ?? declared.trustLevel;
46
+ const decision = evaluatePluginTrust({ trustLevel: declared.trustLevel, permissions }, { verify: opts.verify !== false, trust: opts.trust === true, registryTrustLevel: opts.registryTrustLevel });
47
+ if (decision.allowed) {
48
+ return { commands, hooks, trustLevel, permissions, decision };
49
+ }
50
+ return {
51
+ commands: [],
52
+ hooks: [],
53
+ trustLevel,
54
+ permissions,
55
+ withheld: { hooks, commands, reasons: decision.reasons },
56
+ decision,
57
+ };
58
+ }
33
59
  // ============================================================================
34
60
  // Plugin Manager
35
61
  // ============================================================================
@@ -98,11 +124,14 @@ export class PluginManager {
98
124
  /**
99
125
  * Install a plugin from npm
100
126
  */
101
- async installFromNpm(packageName, version) {
127
+ async installFromNpm(packageName, version, opts = {}) {
102
128
  if (!this.manifest) {
103
129
  await this.initialize();
104
130
  }
105
131
  const versionSpec = version ? `${packageName}@${version}` : packageName;
132
+ const verify = opts.verify !== false;
133
+ const warnings = [];
134
+ let tmpDir;
106
135
  try {
107
136
  // Check if already installed
108
137
  if (this.manifest.plugins[packageName]) {
@@ -116,23 +145,56 @@ export class PluginManager {
116
145
  await this.ensureDirectory(installDir);
117
146
  // Validate package name to prevent injection (S-3)
118
147
  validatePackageName(versionSpec);
148
+ // #3557: with --verify, a registry checksum must match the tarball we
149
+ // install. Pack first, hash that exact file, then install from it, so the
150
+ // bytes that were checked are the bytes that get installed.
151
+ let installTarget = versionSpec;
152
+ let verification = verify ? 'npm-integrity' : 'skipped';
153
+ const expected = verify ? parseSha256Checksum(opts.expectedChecksum) : null;
154
+ if (expected) {
155
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruflo-plugin-verify-'));
156
+ const packed = await runNpm(['pack', versionSpec, '--pack-destination', tmpDir, '--json', '--ignore-scripts'], 120000);
157
+ const info = JSON.parse(packed.stdout);
158
+ const filename = info[0]?.filename;
159
+ if (!filename)
160
+ throw new Error(`npm pack returned no tarball for ${versionSpec}`);
161
+ const tarball = path.join(tmpDir, path.basename(filename));
162
+ const actual = sha256Hex(fs.readFileSync(tarball));
163
+ if (actual !== expected) {
164
+ return {
165
+ success: false,
166
+ error: `Checksum mismatch for ${versionSpec}: registry expects sha256:${expected}, ` +
167
+ `downloaded tarball is sha256:${actual}. Refusing to install (--verify).`,
168
+ };
169
+ }
170
+ installTarget = tarball;
171
+ verification = 'checksum';
172
+ }
173
+ else if (verify && opts.expectedChecksum) {
174
+ warnings.push(`Registry checksum "${opts.expectedChecksum}" is not a verifiable sha256 digest; ` +
175
+ `relying on npm's own registry integrity check.`);
176
+ }
119
177
  // Use npm to install (array form prevents shell injection)
120
178
  console.log(`[PluginManager] Installing ${versionSpec}...`);
121
- await runNpm(['install', '--prefix', this.config.pluginsDir, versionSpec], 120000);
179
+ // #3557 follow-up: lifecycle scripts run before the package's own trust
180
+ // declaration can be read, so untrusted installs skip them entirely.
181
+ const scriptsRun = shouldRunInstallScripts(opts);
182
+ const installArgs = ['install', '--prefix', this.config.pluginsDir, installTarget];
183
+ if (!scriptsRun) {
184
+ installArgs.push('--ignore-scripts');
185
+ warnings.push(`Install scripts were skipped for ${packageName} (--ignore-scripts): it is not registry-vouched and --trust was not given. ` +
186
+ `Reinstall with --trust to run them.`);
187
+ }
188
+ await runNpm(installArgs, 120000);
122
189
  // Get installed version
123
190
  const packageJsonPath = path.join(installDir, packageName, 'package.json');
124
191
  let installedVersion = version || 'latest';
125
- let commands = [];
126
- let hooks = [];
192
+ let pkg = {};
127
193
  if (fs.existsSync(packageJsonPath)) {
128
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
129
- installedVersion = pkg.version;
130
- // Check for claude-flow plugin metadata
131
- if (pkg['claude-flow']) {
132
- commands = pkg['claude-flow'].commands || [];
133
- hooks = pkg['claude-flow'].hooks || [];
134
- }
194
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
195
+ installedVersion = String(pkg.version ?? installedVersion);
135
196
  }
197
+ const trusted = applyTrustPolicy(pkg, opts);
136
198
  // Create plugin entry
137
199
  const plugin = {
138
200
  name: packageName,
@@ -141,25 +203,34 @@ export class PluginManager {
141
203
  enabled: true,
142
204
  source: 'npm',
143
205
  path: path.join(installDir, packageName),
144
- commands,
145
- hooks,
206
+ commands: trusted.commands,
207
+ hooks: trusted.hooks,
208
+ trustLevel: trusted.trustLevel,
209
+ permissions: trusted.permissions,
210
+ verification,
211
+ scriptsRun,
212
+ ...(trusted.withheld ? { withheld: trusted.withheld } : {}),
146
213
  };
147
214
  // Save to manifest
148
215
  this.manifest.plugins[packageName] = plugin;
149
216
  await this.saveManifest();
150
217
  console.log(`[PluginManager] Installed ${packageName}@${installedVersion}`);
151
- return { success: true, plugin };
218
+ return { success: true, plugin, decision: trusted.decision, warnings };
152
219
  }
153
220
  catch (error) {
154
221
  const errorMsg = error instanceof Error ? error.message : String(error);
155
222
  console.error(`[PluginManager] Failed to install ${packageName}:`, errorMsg);
156
223
  return { success: false, error: errorMsg };
157
224
  }
225
+ finally {
226
+ if (tmpDir)
227
+ fs.rmSync(tmpDir, { recursive: true, force: true });
228
+ }
158
229
  }
159
230
  /**
160
231
  * Install a plugin from a local path
161
232
  */
162
- async installFromLocal(sourcePath) {
233
+ async installFromLocal(sourcePath, opts = {}) {
163
234
  if (!this.manifest) {
164
235
  await this.initialize();
165
236
  }
@@ -182,6 +253,10 @@ export class PluginManager {
182
253
  error: `Plugin ${packageName} is already installed`,
183
254
  };
184
255
  }
256
+ // #3557: record declared trust/permissions; withhold hooks and commands
257
+ // the policy doesn't allow without --trust. A local path has no registry
258
+ // entry to vouch for it, so the plugin's own declaration decides.
259
+ const trusted = applyTrustPolicy(pkg, { verify: opts.verify, trust: opts.trust });
185
260
  // Create plugin entry (link to local path, don't copy)
186
261
  const plugin = {
187
262
  name: packageName,
@@ -190,14 +265,20 @@ export class PluginManager {
190
265
  enabled: true,
191
266
  source: 'local',
192
267
  path: absolutePath,
193
- commands: pkg['claude-flow']?.commands || [],
194
- hooks: pkg['claude-flow']?.hooks || [],
268
+ commands: trusted.commands,
269
+ hooks: trusted.hooks,
270
+ trustLevel: trusted.trustLevel,
271
+ permissions: trusted.permissions,
272
+ verification: opts.verify === false ? 'skipped' : 'policy',
273
+ // A local install links the path; it never runs npm or package scripts.
274
+ scriptsRun: false,
275
+ ...(trusted.withheld ? { withheld: trusted.withheld } : {}),
195
276
  };
196
277
  // Save to manifest
197
278
  this.manifest.plugins[packageName] = plugin;
198
279
  await this.saveManifest();
199
280
  console.log(`[PluginManager] Installed local plugin ${packageName}@${pkg.version}`);
200
- return { success: true, plugin };
281
+ return { success: true, plugin, decision: trusted.decision };
201
282
  }
202
283
  catch (error) {
203
284
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -345,8 +426,13 @@ export class PluginManager {
345
426
  const versionSpec = version ? `${packageName}@${version}` : `${packageName}@latest`;
346
427
  // Validate package name to prevent injection (S-3)
347
428
  validatePackageName(versionSpec);
348
- // Reinstall with new version (array form prevents shell injection)
349
- await runNpm(['install', '--prefix', this.config.pluginsDir, versionSpec], 120000);
429
+ // Reinstall with new version (array form prevents shell injection).
430
+ // An install recorded with scriptsRun:false stays script-free on upgrade;
431
+ // legacy entries (no field) keep their pre-#3557 behaviour.
432
+ const upgradeArgs = ['install', '--prefix', this.config.pluginsDir, versionSpec];
433
+ if (existing.scriptsRun === false)
434
+ upgradeArgs.push('--ignore-scripts');
435
+ await runNpm(upgradeArgs, 120000);
350
436
  // Update manifest
351
437
  const installDir = path.join(this.config.pluginsDir, 'node_modules');
352
438
  const packageJsonPath = path.join(installDir, packageName, 'package.json');