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.
- package/.claude/.proven-config-version +1 -0
- package/.claude/helpers/hook-handler.cjs +7 -4
- package/.claude/helpers/memory.cjs +1 -1
- package/.claude/helpers/router.cjs +1 -1
- package/.claude/helpers/session.cjs +1 -1
- package/.claude/proven-config.json +42 -0
- package/.claude-plugin/marketplace.json +16 -1
- package/README.md +1 -53
- package/README.zh-CN.md +1 -53
- package/node_modules/@claude-flow/codex/package.json +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +2 -6
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.js +35 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts +18 -0
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/package.json +1 -1
- package/package.json +2 -2
- package/v3/@claude-flow/cli/README.md +3 -53
- package/v3/@claude-flow/cli/catalog-manifest.json +4 -4
- package/v3/@claude-flow/cli/dist/src/commands/doctor.d.ts +19 -1
- package/v3/@claude-flow/cli/dist/src/commands/doctor.js +69 -8
- package/v3/@claude-flow/cli/dist/src/commands/hooks.js +7 -4
- package/v3/@claude-flow/cli/dist/src/commands/memory.js +30 -7
- package/v3/@claude-flow/cli/dist/src/commands/plugins.js +44 -7
- package/v3/@claude-flow/cli/dist/src/commands/policy.js +5 -2
- package/v3/@claude-flow/cli/dist/src/commands/session.js +128 -21
- package/v3/@claude-flow/cli/dist/src/commands/swarm.js +11 -11
- package/v3/@claude-flow/cli/dist/src/index.js +10 -1
- package/v3/@claude-flow/cli/dist/src/init/executor.js +11 -5
- package/v3/@claude-flow/cli/dist/src/init/helper-companions.d.ts +3 -0
- package/v3/@claude-flow/cli/dist/src/init/helper-companions.js +34 -0
- package/v3/@claude-flow/cli/dist/src/init/helper-integrity.d.ts +21 -0
- package/v3/@claude-flow/cli/dist/src/init/helper-integrity.js +62 -0
- package/v3/@claude-flow/cli/dist/src/init/helper-refresh.d.ts +18 -11
- package/v3/@claude-flow/cli/dist/src/init/helper-refresh.js +56 -13
- package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +10 -10
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.d.ts +16 -3
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +87 -14
- package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.d.ts +7 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +43 -6
- package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.d.ts +15 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +236 -50
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +6 -3
- package/v3/@claude-flow/cli/dist/src/plugins/manager.d.ts +37 -10
- package/v3/@claude-flow/cli/dist/src/plugins/manager.js +106 -20
- package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.d.ts +61 -0
- package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.js +84 -0
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +6 -0
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +27 -2
- package/v3/@claude-flow/cli/package.json +2 -2
|
@@ -69,17 +69,126 @@ function listSessions() {
|
|
|
69
69
|
}
|
|
70
70
|
return sessions;
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 { /*
|
|
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:
|
|
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
|
|
211
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
504
|
-
parsed =
|
|
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
|
-
|
|
507
|
-
return { error:
|
|
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 =
|
|
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
|
-
|
|
3399
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
let hooks = [];
|
|
192
|
+
let pkg = {};
|
|
127
193
|
if (fs.existsSync(packageJsonPath)) {
|
|
128
|
-
|
|
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:
|
|
194
|
-
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
|
-
|
|
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');
|