@claude-flow/cli 3.41.4 → 3.42.1

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 (43) hide show
  1. package/.claude/helpers/.helpers-version +1 -1
  2. package/.claude/helpers/helpers.manifest.json +2 -2
  3. package/catalog-manifest.json +2 -2
  4. package/dist/src/commands/doctor.d.ts +7 -0
  5. package/dist/src/commands/doctor.js +58 -35
  6. package/dist/src/commands/hive-mind.js +4 -3
  7. package/dist/src/commands/swarm.js +89 -18
  8. package/dist/src/init/claudemd-generator.js +2 -2
  9. package/dist/src/mcp-server.js +12 -0
  10. package/dist/src/mcp-tools/agentbbs-tools.d.ts +15 -0
  11. package/dist/src/mcp-tools/agentbbs-tools.js +98 -14
  12. package/dist/src/mcp-tools/agentdb-tools.js +17 -1
  13. package/dist/src/mcp-tools/hive-mind-tools.d.ts +8 -0
  14. package/dist/src/mcp-tools/hive-mind-tools.js +80 -2
  15. package/dist/src/mcp-tools/memory-tools.js +102 -31
  16. package/dist/src/mcp-tools/policy-enforcer.d.ts +126 -0
  17. package/dist/src/mcp-tools/policy-enforcer.js +177 -0
  18. package/dist/src/mcp-tools/seraphina-tools.js +7 -2
  19. package/dist/src/mcp-tools/x-federation-tools.d.ts +12 -0
  20. package/dist/src/mcp-tools/x-federation-tools.js +87 -5
  21. package/dist/src/memory/intelligence.d.ts +14 -2
  22. package/dist/src/memory/intelligence.js +33 -12
  23. package/dist/src/memory/memory-bridge.js +10 -2
  24. package/dist/src/memory/memory-initializer.js +19 -2
  25. package/dist/src/ruvector/graph-backend.js +112 -26
  26. package/dist/src/services/policy-runtime.js +63 -5
  27. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +4 -0
  28. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
  29. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +38 -1
  30. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
  31. package/node_modules/@claude-flow/codex/package.json +2 -1
  32. package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
  33. package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
  34. package/node_modules/@claude-flow/security/dist/index.js +1 -1
  35. package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
  36. package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts +11 -0
  37. package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts.map +1 -1
  38. package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js +0 -0
  39. package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js.map +1 -1
  40. package/node_modules/@claude-flow/security/package.json +1 -0
  41. package/package.json +1 -1
  42. package/dist/src/ruvector/diskann-backend.d.ts +0 -78
  43. package/dist/src/ruvector/diskann-backend.js +0 -310
@@ -34,7 +34,7 @@
34
34
  *
35
35
  * @module @claude-flow/cli/mcp-tools/agentbbs
36
36
  */
37
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
38
38
  import { resolve, isAbsolute, join } from 'node:path';
39
39
  import { randomBytes, createHash } from 'node:crypto';
40
40
  import { execFileSync } from 'node:child_process';
@@ -133,12 +133,83 @@ function readEnvelopes(path) {
133
133
  }
134
134
  return out;
135
135
  }
136
- function nextSeq(path) {
136
+ // Tail of the log we scan to find the last seq. Envelopes are small; one line
137
+ // always fits well inside this. Reading only the tail keeps append O(1) instead
138
+ // of O(n) as a room log grows (ADR-164 §3.2.2 requires monotonic seq, and a
139
+ // full re-parse on every publish was the cost).
140
+ const SEQ_TAIL_BYTES = 65536;
141
+ /** Last envelope's seq by scanning only the file tail; full-read fallback keeps it exact. */
142
+ export function nextSeq(path) {
143
+ if (!existsSync(path))
144
+ return 1;
145
+ try {
146
+ const size = statSync(path).size;
147
+ if (size === 0)
148
+ return 1;
149
+ const start = Math.max(0, size - SEQ_TAIL_BYTES);
150
+ const fd = openSync(path, 'r');
151
+ let text;
152
+ try {
153
+ const buf = Buffer.alloc(size - start);
154
+ readSync(fd, buf, 0, buf.length, start);
155
+ text = buf.toString('utf-8');
156
+ }
157
+ finally {
158
+ closeSync(fd);
159
+ }
160
+ // If we started mid-file the first fragment may be a partial line — drop it.
161
+ const lines = text.split(/\r?\n/).filter(l => l.trim());
162
+ const candidates = start > 0 && lines.length > 1 ? lines.slice(1) : lines;
163
+ for (let i = candidates.length - 1; i >= 0; i--) {
164
+ try {
165
+ const e = JSON.parse(candidates[i]);
166
+ if (typeof e.seq === 'number')
167
+ return e.seq + 1;
168
+ }
169
+ catch { /* keep scanning upward for the last parseable line */ }
170
+ }
171
+ }
172
+ catch { /* fall through to the exact full-read path */ }
173
+ // Fallback: whole-file parse (rare — huge single line, or a tail with no seq).
137
174
  const env = readEnvelopes(path);
138
175
  if (env.length === 0)
139
176
  return 1;
140
177
  return (env[env.length - 1].seq ?? env.length) + 1;
141
178
  }
179
+ /** Window within which a fresh PeerHello is suppressed as a duplicate (heartbeat, not spam). */
180
+ const HELLO_WINDOW_SECS = Number(process.env.FEDERATION_BBS_HELLO_WINDOW_SECS ?? 60);
181
+ /** Timestamp (ms) of the most recent PeerHello for a room, or -Infinity if none. */
182
+ export function lastPeerHelloMs(logPath) {
183
+ if (!existsSync(logPath))
184
+ return -Infinity;
185
+ const env = readEnvelopes(logPath);
186
+ for (let i = env.length - 1; i >= 0; i--) {
187
+ if (env[i].msgType === 'PeerHello') {
188
+ const t = Date.parse(env[i].timestamp);
189
+ return Number.isNaN(t) ? -Infinity : t;
190
+ }
191
+ }
192
+ return -Infinity;
193
+ }
194
+ /**
195
+ * Whether a `register` call should append a PeerHello: only for a genuinely new
196
+ * registration, or once the heartbeat window has elapsed since the last one.
197
+ * A re-register within the window is a retry and must NOT spam a near-identical
198
+ * hello. Pure — the decision the handler makes, exported for test.
199
+ */
200
+ export function shouldEmitHello(alreadyRegistered, nowMs, lastHelloMs, windowSecs) {
201
+ return !alreadyRegistered || nowMs - lastHelloMs >= windowSecs * 1000;
202
+ }
203
+ /** Collapse duplicate envelopeIds, preserving order (first occurrence wins). Pure. */
204
+ export function dedupEnvelopes(list) {
205
+ const seen = new Set();
206
+ return list.filter(e => {
207
+ if (seen.has(e.envelopeId))
208
+ return false;
209
+ seen.add(e.envelopeId);
210
+ return true;
211
+ });
212
+ }
142
213
  /**
143
214
  * Ephemeral per-process Ed25519 keypair for human-join token signing.
144
215
  * Phase 1 contract: keys are NOT persisted across process restart — every
@@ -212,6 +283,7 @@ export const agentbbsTools = [
212
283
  const registryPath = roomsRegistryPath(basePath);
213
284
  const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf-8')) : {};
214
285
  // Idempotent — re-registering the same label updates timestamp but keeps id stable.
286
+ const alreadyRegistered = roomId in registry;
215
287
  const entry = {
216
288
  roomId,
217
289
  roomLabel,
@@ -220,17 +292,25 @@ export const agentbbsTools = [
220
292
  };
221
293
  registry[roomId] = entry;
222
294
  writeFileSync(registryPath, JSON.stringify(registry, null, 2));
223
- // Emit a synthetic PeerHello envelope into the room log so watchers see the join.
295
+ // Emit a PeerHello only when it carries information: a genuinely new
296
+ // registration, or a heartbeat refresh once HELLO_WINDOW_SECS has elapsed.
297
+ // A re-register within the window is a retry/no-op — appending a
298
+ // near-identical hello every call is the spam ADR-164 dedup guards against
299
+ // (observed live: one node published 8 near-identical PeerHellos).
224
300
  const logPath = roomLogPath(basePath, roomId);
225
- const env = {
226
- envelopeId: base64url(randomBytes(12)),
227
- roomId,
228
- seq: nextSeq(logPath),
229
- msgType: 'PeerHello',
230
- payload: { roomLabel, trustLevel: 'attested' },
231
- timestamp: entry.registeredAt,
232
- };
233
- appendFileSync(logPath, JSON.stringify(env) + '\n');
301
+ const nowMs = Date.parse(entry.registeredAt);
302
+ const helloEmitted = shouldEmitHello(alreadyRegistered, nowMs, lastPeerHelloMs(logPath), HELLO_WINDOW_SECS);
303
+ if (helloEmitted) {
304
+ const env = {
305
+ envelopeId: base64url(randomBytes(12)),
306
+ roomId,
307
+ seq: nextSeq(logPath),
308
+ msgType: 'PeerHello',
309
+ payload: { roomLabel, trustLevel: 'attested' },
310
+ timestamp: entry.registeredAt,
311
+ };
312
+ appendFileSync(logPath, JSON.stringify(env) + '\n');
313
+ }
234
314
  // nodeId: deterministic per (cwd, roomId) so re-registers reuse identity.
235
315
  const nodeId = createHash('sha256')
236
316
  .update(`agentbbs:node:${basePath}:${roomId}`)
@@ -241,6 +321,7 @@ export const agentbbsTools = [
241
321
  roomId,
242
322
  nodeId,
243
323
  trustLevel: 'attested',
324
+ helloEmitted,
244
325
  };
245
326
  },
246
327
  },
@@ -357,13 +438,16 @@ export const agentbbsTools = [
357
438
  const idx = all.findIndex(e => e.envelopeId === sinceEnvelopeId);
358
439
  slice = idx >= 0 ? all.slice(idx + 1) : all;
359
440
  }
360
- const envelopes = slice.slice(-limit);
441
+ // Dedup by envelopeId before applying the limit — a relay replay or a
442
+ // double-append must not surface the same envelope twice to a watcher.
443
+ const deduped = dedupEnvelopes(slice);
444
+ const envelopes = deduped.slice(-limit);
361
445
  return {
362
446
  success: true,
363
447
  roomId,
364
448
  envelopes,
365
449
  count: envelopes.length,
366
- hasMore: slice.length > envelopes.length,
450
+ hasMore: deduped.length > envelopes.length,
367
451
  };
368
452
  },
369
453
  },
@@ -303,13 +303,24 @@ export const agentdbPatternSearch = {
303
303
  // Tier 1 — semantic
304
304
  let results = [];
305
305
  let tier = 'semantic';
306
+ // #3325: a thrown error and a genuine zero-match were indistinguishable
307
+ // — `catch {}` discarded the former, `semantic?.results ?? []` treated
308
+ // `{success: false, error}` (which searchEntries returns WITHOUT
309
+ // throwing) as the latter. Capture whichever fires so a caller that
310
+ // lands on tier=substring can tell why, instead of source-reading.
311
+ let semanticError;
306
312
  try {
307
313
  const semantic = await searchEntries({ query, namespace: 'pattern', limit: topK });
314
+ if (semantic && semantic.success === false) {
315
+ semanticError = semantic.error ?? 'searchEntries returned success:false';
316
+ }
308
317
  results = (semantic?.results ?? [])
309
318
  .map(parseEntry)
310
319
  .filter((r) => r !== null);
311
320
  }
312
- catch { /* fall through to tier 2 */ }
321
+ catch (err) {
322
+ semanticError = sanitizeError(err);
323
+ }
313
324
  // Tier 2 — substring scan (catches just-written entries before HNSW indexes them).
314
325
  // #2226: listEntries returns metadata only (no content/value — see open #2014),
315
326
  // so parseEntry would always null out here. Fetch each entry's content by key via
@@ -358,6 +369,11 @@ export const agentdbPatternSearch = {
358
369
  reason: result ? `reasoningBank-empty:${result.controller ?? 'unknown'}` : 'reasoningBank-unavailable:registry-null',
359
370
  controller: 'memory-store-fallback',
360
371
  tier,
372
+ // #3325: when tier=substring because tier 1 hit a real error (thrown,
373
+ // or {success:false, error}) rather than a genuine zero-match,
374
+ // surface why — otherwise a hard failure and an honest "not found"
375
+ // are indistinguishable from the response alone.
376
+ ...(tier === 'substring' && semanticError ? { semanticError } : {}),
361
377
  note: result
362
378
  ? `ReasoningBank returned 0 results; tier=${tier} from pattern namespace.`
363
379
  : `ReasoningBank controller unavailable; tier=${tier} from pattern namespace.`,
@@ -4,5 +4,13 @@
4
4
  * Tool definitions for collective intelligence and swarm coordination.
5
5
  */
6
6
  import { type MCPTool } from './types.js';
7
+ /**
8
+ * Read the current hiveToken directly off disk, for same-machine callers
9
+ * that already have filesystem access to hive state (the CLI's own
10
+ * `hive-mind join/leave/consensus` subcommands) -- NOT exposed over any MCP
11
+ * tool response (in particular, not hive-mind_status), since that's a
12
+ * remote-reachable surface a capability token must not leak through.
13
+ */
14
+ export declare function getHiveTokenForCli(): string | undefined;
7
15
  export declare const hiveMindTools: MCPTool[];
8
16
  //# sourceMappingURL=hive-mind-tools.d.ts.map
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
8
9
  import { getProjectCwd } from './types.js';
9
10
  import { validateIdentifier, validateText } from './validate-input.js';
10
11
  // Storage paths
@@ -83,6 +84,36 @@ function tryResolveProposal(proposal, totalNodes) {
83
84
  }
84
85
  return null;
85
86
  }
87
+ /**
88
+ * Verify a caller-supplied hiveToken against the one minted by hive-mind_init,
89
+ * using a constant-time comparison (bearer-token capability check -- callers
90
+ * that never joined, and callers that guess/omit the token, get identical
91
+ * rejection). Returns an error string on failure, or null on success.
92
+ */
93
+ function requireHiveToken(state, suppliedToken) {
94
+ if (!state.hiveToken) {
95
+ return 'Hive-mind has no capability token minted (re-run hive-mind_init)';
96
+ }
97
+ if (typeof suppliedToken !== 'string' || !suppliedToken) {
98
+ return 'hiveToken is required';
99
+ }
100
+ const expected = Buffer.from(state.hiveToken, 'utf-8');
101
+ const actual = Buffer.from(suppliedToken, 'utf-8');
102
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
103
+ return 'Invalid hiveToken';
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Read the current hiveToken directly off disk, for same-machine callers
109
+ * that already have filesystem access to hive state (the CLI's own
110
+ * `hive-mind join/leave/consensus` subcommands) -- NOT exposed over any MCP
111
+ * tool response (in particular, not hive-mind_status), since that's a
112
+ * remote-reachable surface a capability token must not leak through.
113
+ */
114
+ export function getHiveTokenForCli() {
115
+ return loadHiveState().hiveToken;
116
+ }
86
117
  function getHiveDir() {
87
118
  return join(getProjectCwd(), STORAGE_DIR, HIVE_DIR);
88
119
  }
@@ -248,6 +279,14 @@ export const hiveMindTools = [
248
279
  electedAt: new Date().toISOString(),
249
280
  term: 1,
250
281
  };
282
+ // Mint a capability token on first init; a re-init (topology/consensus
283
+ // change on an already-initialized hive) keeps the existing token and
284
+ // roster rather than silently invalidating workers who already hold
285
+ // it -- only re-generated if somehow absent (e.g. state predates this
286
+ // field).
287
+ if (!state.hiveToken) {
288
+ state.hiveToken = randomBytes(32).toString('hex');
289
+ }
251
290
  saveHiveState(state);
252
291
  return {
253
292
  success: true,
@@ -256,6 +295,7 @@ export const hiveMindTools = [
256
295
  consensus: state.consensusStrategy,
257
296
  queenId,
258
297
  status: 'initialized',
298
+ hiveToken: state.hiveToken,
259
299
  config: {
260
300
  topology: state.topology,
261
301
  consensus: state.consensusStrategy,
@@ -375,8 +415,9 @@ export const hiveMindTools = [
375
415
  properties: {
376
416
  agentId: { type: 'string', description: 'Agent ID to join' },
377
417
  role: { type: 'string', enum: ['worker', 'specialist', 'scout'], description: 'Agent role in hive' },
418
+ hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init' },
378
419
  },
379
- required: ['agentId'],
420
+ required: ['agentId', 'hiveToken'],
380
421
  },
381
422
  handler: async (input) => {
382
423
  const state = loadHiveState();
@@ -389,6 +430,13 @@ export const hiveMindTools = [
389
430
  if (!state.initialized) {
390
431
  return { success: false, error: 'Hive-mind not initialized' };
391
432
  }
433
+ // Fail-closed: an unrecognized/missing token makes no membership
434
+ // change at all -- state.workers is untouched, not just left
435
+ // unsaved (the write below never happens on this path).
436
+ const tokenError = requireHiveToken(state, input.hiveToken);
437
+ if (tokenError) {
438
+ return { success: false, agentId, error: tokenError };
439
+ }
392
440
  if (!state.workers.includes(agentId)) {
393
441
  state.workers.push(agentId);
394
442
  saveHiveState(state);
@@ -410,8 +458,9 @@ export const hiveMindTools = [
410
458
  type: 'object',
411
459
  properties: {
412
460
  agentId: { type: 'string', description: 'Agent ID to remove' },
461
+ hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init' },
413
462
  },
414
- required: ['agentId'],
463
+ required: ['agentId', 'hiveToken'],
415
464
  },
416
465
  handler: async (input) => {
417
466
  const state = loadHiveState();
@@ -421,6 +470,12 @@ export const hiveMindTools = [
421
470
  if (!v.valid)
422
471
  return { success: false, agentId, error: v.error };
423
472
  }
473
+ // Fail-closed: a denied caller makes no membership change -- the
474
+ // splice/save below is unreachable on this path.
475
+ const tokenError = requireHiveToken(state, input.hiveToken);
476
+ if (tokenError) {
477
+ return { success: false, agentId, error: tokenError };
478
+ }
424
479
  const index = state.workers.indexOf(agentId);
425
480
  if (index > -1) {
426
481
  state.workers.splice(index, 1);
@@ -448,6 +503,7 @@ export const hiveMindTools = [
448
503
  value: { description: 'Proposal value (for propose)' },
449
504
  vote: { type: 'boolean', description: 'Vote (true=for, false=against)' },
450
505
  voterId: { type: 'string', description: 'Voter agent ID' },
506
+ hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init (required to vote)' },
451
507
  strategy: { type: 'string', enum: ['bft', 'raft', 'quorum'], description: 'Consensus strategy (default: raft)' },
452
508
  quorumPreset: { type: 'string', enum: ['unanimous', 'majority', 'supermajority'], description: 'Quorum threshold preset (for quorum strategy, default: majority)' },
453
509
  term: { type: 'number', description: 'Term number (for raft strategy)' },
@@ -531,6 +587,28 @@ export const hiveMindTools = [
531
587
  if (!voterId) {
532
588
  return { action, error: 'voterId is required for voting' };
533
589
  }
590
+ // Fail-closed: a denied caller records no vote at all -- the
591
+ // votes[voterId] write below is unreachable on this path, and
592
+ // nothing about the proposal (vote tallies, status) changes.
593
+ const tokenError = requireHiveToken(state, input.hiveToken);
594
+ if (tokenError) {
595
+ return { action, error: tokenError, proposalId: proposal.proposalId };
596
+ }
597
+ // voterId was previously trusted as-is: any caller-supplied string
598
+ // was recorded into proposal.votes and counted toward
599
+ // calculateRequiredVotes()'s threshold (derived from
600
+ // state.workers.length), with no check that it named a worker who
601
+ // actually joined this hive-mind. That let a single caller cross
602
+ // any strategy's quorum (raft/bft/quorum alike) by voting under
603
+ // fabricated ids — a Sybil attack on consensus, not merely a
604
+ // double-vote. Require the voter to be a registered worker.
605
+ if (!state.workers.includes(voterId)) {
606
+ return {
607
+ action,
608
+ error: `Voter ${voterId} is not a registered hive-mind worker`,
609
+ proposalId: proposal.proposalId,
610
+ };
611
+ }
534
612
  const voteValue = input.vote;
535
613
  const proposalStrategy = proposal.strategy || 'raft';
536
614
  const required = calculateRequiredVotes(proposalStrategy, totalNodes, proposal.quorumPreset);
@@ -238,6 +238,58 @@ async function describeBackend() {
238
238
  return 'sqlite';
239
239
  }
240
240
  }
241
+ /** #3311: one page per round trip, so a store larger than one page is
242
+ * counted rather than silently cut off at the old hardcoded 100000. */
243
+ const MEMORY_STATS_PAGE = 10000;
244
+ /** #3311: a hard stop so a `total` that never agrees with the rows returned
245
+ * cannot spin forever. Reaching it is reported as a truncated count, not as
246
+ * a complete one. */
247
+ const MEMORY_STATS_MAX_PAGES = 200;
248
+ /**
249
+ * Page through the store and return every row, or the first failure.
250
+ *
251
+ * The old call asked for `limit: 100000` in one shot and read only
252
+ * `entries`/`total` off the result — so a bigger store had its namespace
253
+ * breakdown and embedding coverage computed from a prefix, and a listing
254
+ * that reported `success: false` was read as an empty one.
255
+ */
256
+ async function collectAllEntries(listEntries) {
257
+ const entries = [];
258
+ let total = 0;
259
+ for (let page = 0; page < MEMORY_STATS_MAX_PAGES; page++) {
260
+ const result = await listEntries({ limit: MEMORY_STATS_PAGE, offset: entries.length });
261
+ if (!result.success) {
262
+ return { success: false, entries, total: result.total ?? total, error: result.error };
263
+ }
264
+ total = result.total;
265
+ entries.push(...result.entries);
266
+ if (result.entries.length === 0 || entries.length >= total)
267
+ break;
268
+ }
269
+ return { success: true, entries, total };
270
+ }
271
+ /** #3311: unavailable is its own answer. The zeros this replaces were
272
+ * indistinguishable from a store that really is empty. */
273
+ function memoryStatsUnavailable(error) {
274
+ return { initialized: null, available: false, error };
275
+ }
276
+ /**
277
+ * Version and feature labels from the initialization probe.
278
+ *
279
+ * Metadata only. The probe reads a whole-image snapshot that cannot see live
280
+ * WAL frames, so it is allowed to fail without that meaning the store is
281
+ * missing — which is exactly the conflation #3311 reports.
282
+ */
283
+ async function readMemoryStatusLabels() {
284
+ try {
285
+ const { checkMemoryInitialization } = await getMemoryFunctions();
286
+ const status = await checkMemoryInitialization();
287
+ return { version: status.version, features: status.features };
288
+ }
289
+ catch {
290
+ return {};
291
+ }
292
+ }
241
293
  /**
242
294
  * Ensure memory database is initialized and migrate legacy data if needed.
243
295
  * #1606: Wrapped in try/catch to prevent process-level crashes that kill
@@ -704,41 +756,60 @@ export const memoryTools = [
704
756
  },
705
757
  handler: async () => {
706
758
  await ensureInitialized();
707
- const { checkMemoryInitialization, listEntries } = await getMemoryFunctions();
759
+ const { listEntries } = await getMemoryFunctions();
760
+ // #3311: the store's own listing decides whether memory is there.
761
+ // `checkMemoryInitialization` opens a whole-image sql.js snapshot of
762
+ // the main database file, which cannot include live SQLite WAL frames
763
+ // — so a store the bridge reads and searches perfectly well came back
764
+ // `initialized: false` from this tool alone. The probe is still read
765
+ // below, for the version and feature labels it is the only source of,
766
+ // but it no longer gets to overrule a working store.
767
+ let listing;
708
768
  try {
709
- const status = await checkMemoryInitialization();
710
- const allEntries = await listEntries({ limit: 100000 });
711
- // Count by namespace
712
- const namespaces = {};
713
- let withEmbeddings = 0;
714
- for (const entry of allEntries.entries) {
715
- namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
716
- if (entry.hasEmbedding)
717
- withEmbeddings++;
718
- }
719
- return {
720
- initialized: status.initialized,
721
- totalEntries: allEntries.total,
722
- entriesWithEmbeddings: withEmbeddings,
723
- embeddingCoverage: allEntries.total > 0
724
- ? `${((withEmbeddings / allEntries.total) * 100).toFixed(1)}%`
725
- : '0%',
726
- namespaces,
727
- backend: await describeBackend(),
728
- version: status.version || '3.0.0',
729
- features: status.features || {
730
- vectorEmbeddings: true,
731
- hnswIndex: true,
732
- semanticSearch: true,
733
- },
734
- };
769
+ listing = await collectAllEntries(listEntries);
735
770
  }
736
771
  catch (error) {
737
- return {
738
- initialized: false,
739
- error: error instanceof Error ? error.message : 'Unknown error',
740
- };
772
+ return memoryStatsUnavailable(error instanceof Error ? error.message : 'Unknown error');
773
+ }
774
+ if (!listing.success) {
775
+ // A failed query is not an empty store. Reporting zeros here is
776
+ // what made a WAL refusal look like "you have no memories".
777
+ return memoryStatsUnavailable(listing.error || 'listEntries reported failure');
741
778
  }
779
+ // Object.create(null): a namespace literally named `__proto__` is a
780
+ // legal key, and assigning it on an object literal sets the prototype
781
+ // instead of counting anything — so that namespace's entries vanished
782
+ // from the breakdown while still being counted in the total.
783
+ const namespaces = Object.create(null);
784
+ let withEmbeddings = 0;
785
+ for (const entry of listing.entries) {
786
+ namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
787
+ if (entry.hasEmbedding)
788
+ withEmbeddings++;
789
+ }
790
+ const counted = listing.entries.length;
791
+ const status = await readMemoryStatusLabels();
792
+ return {
793
+ initialized: true,
794
+ totalEntries: listing.total,
795
+ entriesCounted: counted,
796
+ // The breakdown below covers `entriesCounted` rows, which is every
797
+ // row unless the listing was truncated; say so rather than letting
798
+ // a partial count read as the whole store.
799
+ ...(counted < listing.total ? { truncated: true } : {}),
800
+ entriesWithEmbeddings: withEmbeddings,
801
+ embeddingCoverage: counted > 0
802
+ ? `${((withEmbeddings / counted) * 100).toFixed(1)}%`
803
+ : '0%',
804
+ namespaces: { ...namespaces },
805
+ backend: await describeBackend(),
806
+ version: status.version || '3.0.0',
807
+ features: status.features || {
808
+ vectorEmbeddings: true,
809
+ hnswIndex: true,
810
+ semanticSearch: true,
811
+ },
812
+ };
742
813
  },
743
814
  },
744
815
  {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * MCP Governance Policy Enforcer (opt-in).
3
+ *
4
+ * `.harness/mcp-policy.json` declares governance intent (defaultDeny,
5
+ * auditLog, maxToolCallsPerTurn, dangerousPatterns, ...) for the claude-flow
6
+ * MCP server, but until now nothing in the running server (mcp-server.ts)
7
+ * ever read it: `harness mcp-scan` grades the file's *posture* offline, the
8
+ * live `tools/call` dispatch never consulted it. Any connected MCP client
9
+ * could call every registered tool with no audit trail and no call budget.
10
+ *
11
+ * This module wires the two policy fields that are actually in this
12
+ * server's jurisdiction, per the policy file's own rationale comment
13
+ * (`dangerousPatterns` / `allowShell` / `allowNetwork` / `allowFileWrite`
14
+ * describe the native-Claude-Code-tool layer — Bash/Write/Edit/WebFetch —
15
+ * not this MCP server's memory_-, hooks_-, agentdb_-prefixed tool surface,
16
+ * so they are intentionally left unenforced here):
17
+ * - `auditLog`: append a JSONL record for every `tools/call`.
18
+ * - `maxToolCallsPerTurn`: bound calls per MCP *session* (one stdio
19
+ * process lifetime), deny once exceeded.
20
+ *
21
+ * Fully opt-in via `RUFLO_MCP_ENFORCE_POLICY=1` (or `true`). Unset/false
22
+ * means every function below is a no-op on the hot path — the pre-existing
23
+ * `tools/call` behavior is unchanged.
24
+ *
25
+ * FAIL-CLOSED once enforcement is enabled (PR #3139 review round 1):
26
+ * a missing/malformed policy file, or a failed mandatory audit-log write,
27
+ * denies the call rather than silently degrading to unrestricted execution.
28
+ * The whole point of opting in is a restriction that actually holds; an
29
+ * enforcement flag that quietly falls back to "no restriction" on its own
30
+ * misconfiguration defeats the feature. See `evaluateToolCall()`.
31
+ *
32
+ * Known scope limits (disclosed, not fixed here):
33
+ * - Only wired into the stdio `tools/call` dispatch
34
+ * (`MCPServerManager.handleMCPMessage`). The separate HTTP/websocket
35
+ * path (`startHttpServer()`, via `@claude-flow/mcp`) does not call
36
+ * this module and is unaffected even when this flag is set.
37
+ *
38
+ * `maxToolCallsPerTurn` reset semantics (dream-cycle 2026-09-01, follow-up
39
+ * to 2026-08-31 review round 1): despite the field's name, the original
40
+ * implementation enforced a *session-lifetime cumulative* cap that never
41
+ * reset — a long-lived stdio session could exhaust the budget under
42
+ * entirely legitimate use and stay locked out until the MCP server process
43
+ * restarted. Research that night (see the dream-cycle gist) found: (1) the
44
+ * MCP spec only mandates "rate limit tool invocations" with zero mechanism
45
+ * guidance, and its 2026-07-28 revision (SEP-2567) is actively removing the
46
+ * session concept from the protocol entirely; (2) every framework/product
47
+ * that gets this right (FastMCP's rate-limiting middleware, the PolicyLayer
48
+ * MCP firewall, Cloudflare's public rate limiter) anchors the reset to
49
+ * wall-clock time, not to a turn or session counter that never decays —
50
+ * a turn-count reset is gameable by a chatty loop re-arming its own budget,
51
+ * which wall-clock time is not. This module now enforces a *sliding
52
+ * wall-clock window*: `maxToolCallsPerTurn` calls are allowed per rolling
53
+ * `turnWindowMs` (default 60000) per session, keyed by call timestamp so
54
+ * calls fall out of the window as time passes rather than accumulating
55
+ * forever. `now` is an injectable parameter (defaults to `Date.now`) so
56
+ * production callers need no change and tests stay fully deterministic via
57
+ * `vi.useFakeTimers()`.
58
+ */
59
+ export interface McpPolicy {
60
+ schema?: number;
61
+ policyVersion?: number;
62
+ harnessId?: string;
63
+ defaultDeny?: boolean;
64
+ auditLog?: boolean;
65
+ requireApprovalForDangerous?: boolean;
66
+ toolTimeoutMs?: number;
67
+ /**
68
+ * Despite the name, this is a WALL-CLOCK rate limit, not a literal
69
+ * conversational-turn counter — MCP has no protocol-level concept of a
70
+ * "turn" to count against (confirmed: the spec is silent on it, and its
71
+ * 2026-07-28 revision removes the session concept entirely). Enforced as
72
+ * "at most this many calls in any rolling `turnWindowMs` window" per
73
+ * session. Treat it, and document it to callers, as rate limiting.
74
+ */
75
+ maxToolCallsPerTurn?: number;
76
+ /** Rolling window (ms) over which `maxToolCallsPerTurn` is counted. Default 60000. */
77
+ turnWindowMs?: number;
78
+ dangerousPatterns?: string[];
79
+ approvedServers?: string[];
80
+ [key: string]: unknown;
81
+ }
82
+ export declare function isPolicyEnforcementEnabled(env?: NodeJS.ProcessEnv): boolean;
83
+ export declare function loadMcpPolicy(policyPath?: string): McpPolicy | null;
84
+ /** Test-only: clear per-session call state between test cases. */
85
+ export declare function resetPolicyEnforcerState(): void;
86
+ export interface PolicyCheckResult {
87
+ allowed: boolean;
88
+ reason?: string;
89
+ }
90
+ /**
91
+ * Checks (and, if allowed, records) a tool call against
92
+ * `policy.maxToolCallsPerTurn`, counted over a sliding window of
93
+ * `policy.turnWindowMs` (default 60000ms) rather than the session's whole
94
+ * lifetime. Calls older than the window are pruned before comparing count
95
+ * to limit, so a session that pauses gets its budget back rather than
96
+ * staying denied until the process restarts. `now` defaults to `Date.now`
97
+ * for production callers; tests inject a controlled clock instead.
98
+ */
99
+ export declare function checkAndRecordCall(policy: McpPolicy, sessionId: string, now?: number): PolicyCheckResult;
100
+ export interface AuditLogEntry {
101
+ timestamp: string;
102
+ sessionId: string;
103
+ toolName: string;
104
+ allowed: boolean;
105
+ reason?: string;
106
+ }
107
+ /** Test-only: redirect the audit log to a temp file instead of the default path. */
108
+ export declare function setAuditLogPathForTesting(p: string | null): void;
109
+ export declare function getAuditLogPath(): string;
110
+ /**
111
+ * Appends one JSONL audit record. Returns `true` if `policy.auditLog` is not
112
+ * set (nothing was required) or the write succeeded; `false` only when
113
+ * `auditLog` is required and the write itself failed (disk full, unwritable
114
+ * path, etc). Never throws — the caller (`evaluateToolCall`) decides what a
115
+ * failed *mandatory* write means for the call (fail-closed: deny it).
116
+ */
117
+ export declare function appendAuditLog(policy: McpPolicy, entry: AuditLogEntry): boolean;
118
+ /**
119
+ * Single enforcement entry point for a `tools/call` dispatch. Combines, in
120
+ * order: fail-closed on a missing/malformed policy, the per-session call
121
+ * budget, and fail-closed on a failed mandatory audit-log write. `policy`
122
+ * is the result of `loadMcpPolicy()` — pass `null` straight through when it
123
+ * failed to load, rather than re-deciding that here.
124
+ */
125
+ export declare function evaluateToolCall(policy: McpPolicy | null, sessionId: string, toolName: string, now?: number): PolicyCheckResult;
126
+ //# sourceMappingURL=policy-enforcer.d.ts.map