@claude-flow/cli 3.41.4 → 3.42.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/helpers/.helpers-version +1 -1
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/hive-mind.js +4 -3
- package/dist/src/init/claudemd-generator.js +2 -2
- package/dist/src/mcp-server.js +12 -0
- package/dist/src/mcp-tools/agentdb-tools.js +17 -1
- package/dist/src/mcp-tools/hive-mind-tools.d.ts +8 -0
- package/dist/src/mcp-tools/hive-mind-tools.js +80 -2
- package/dist/src/mcp-tools/policy-enforcer.d.ts +126 -0
- package/dist/src/mcp-tools/policy-enforcer.js +177 -0
- package/dist/src/memory/intelligence.d.ts +14 -2
- package/dist/src/memory/intelligence.js +33 -12
- package/dist/src/memory/memory-bridge.js +10 -2
- package/dist/src/services/policy-runtime.js +63 -5
- package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +4 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +38 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
- package/node_modules/@claude-flow/codex/package.json +2 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
- package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/index.js +1 -1
- package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts +11 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js +0 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js.map +1 -1
- package/node_modules/@claude-flow/security/package.json +1 -0
- package/package.json +1 -1
- package/dist/src/ruvector/diskann-backend.d.ts +0 -78
- package/dist/src/ruvector/diskann-backend.js +0 -310
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.42.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.42.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "85fe05c757421c52137c0bc8545a0896bab6b4714538c2a11d1d0835bfcc8c1c",
|
|
6
6
|
"hook-handler.cjs": "209d9fafe10e17d1be0866727f6f9cf9ac66f9a0793f1c793a4f58319e8e4583",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"statusline.cjs": "4a48353b4f1566fa4379b00fd0321b6676a22b6cc91fbbd8380ac5183d619468"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"signature": "
|
|
11
|
+
"signature": "Sk/d/KcF51uXVXWVYWTuNHw1s3Rl3uLg87zI0tzfsqutQtUISPujuCmxdnV6lZ1tC0gXP0cbLRMtIdqCPLNvAQ==",
|
|
12
12
|
"algorithm": "ed25519"
|
|
13
13
|
}
|
package/catalog-manifest.json
CHANGED
|
@@ -13,6 +13,7 @@ import { mkdir, writeFile } from 'fs/promises';
|
|
|
13
13
|
import { existsSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { resolveClaudeLaunchCommand } from '../runtime/claude-command.js';
|
|
16
|
+
import { getHiveTokenForCli } from '../mcp-tools/hive-mind-tools.js';
|
|
16
17
|
// Hive topologies
|
|
17
18
|
const TOPOLOGIES = [
|
|
18
19
|
{ value: 'hierarchical', label: 'Hierarchical', hint: 'Queen-led with worker agents' },
|
|
@@ -977,7 +978,7 @@ const joinCommand = {
|
|
|
977
978
|
return { success: false, exitCode: 1 };
|
|
978
979
|
}
|
|
979
980
|
try {
|
|
980
|
-
const result = await callMCPTool('hive-mind_join', { agentId, role: ctx.flags.role });
|
|
981
|
+
const result = await callMCPTool('hive-mind_join', { agentId, role: ctx.flags.role, hiveToken: getHiveTokenForCli() });
|
|
981
982
|
if (!result.success) {
|
|
982
983
|
output.printError(result.error || 'Failed');
|
|
983
984
|
return { success: false, exitCode: 1 };
|
|
@@ -1003,7 +1004,7 @@ const leaveCommand = {
|
|
|
1003
1004
|
return { success: false, exitCode: 1 };
|
|
1004
1005
|
}
|
|
1005
1006
|
try {
|
|
1006
|
-
const result = await callMCPTool('hive-mind_leave', { agentId });
|
|
1007
|
+
const result = await callMCPTool('hive-mind_leave', { agentId, hiveToken: getHiveTokenForCli() });
|
|
1007
1008
|
if (!result.success) {
|
|
1008
1009
|
output.printError(result.error || 'Failed');
|
|
1009
1010
|
return { success: false, exitCode: 1 };
|
|
@@ -1032,7 +1033,7 @@ const consensusCommand = {
|
|
|
1032
1033
|
action: async (ctx) => {
|
|
1033
1034
|
const action = ctx.flags.action || 'list';
|
|
1034
1035
|
try {
|
|
1035
|
-
const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId });
|
|
1036
|
+
const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId, hiveToken: getHiveTokenForCli() });
|
|
1036
1037
|
if (ctx.flags.format === 'json') {
|
|
1037
1038
|
output.printJson(result);
|
|
1038
1039
|
return { success: true, data: result };
|
|
@@ -258,7 +258,7 @@ function performanceSection() {
|
|
|
258
258
|
|
|
259
259
|
- Always benchmark before AND after optimization
|
|
260
260
|
- Always profile before optimizing — never guess bottlenecks
|
|
261
|
-
- Use HNSW
|
|
261
|
+
- Use HNSW for vector search, Int8 quantization for memory reduction — measure the actual speedup/recall on your own corpus via \`node scripts/benchmark-intelligence.mjs --only=hnsw\` rather than trusting a hardcoded multiplier (recall@10 varies by corpus size — 0.99 at N=5k, 0.92 at N=20k on the reference host, dream-cycle 2026-08-30)
|
|
262
262
|
|
|
263
263
|
\`\`\`bash
|
|
264
264
|
npx @claude-flow/cli@latest performance benchmark --suite all
|
|
@@ -293,7 +293,7 @@ function intelligenceSystem() {
|
|
|
293
293
|
Pipeline: **RETRIEVE** (vector search) → **JUDGE** (success/failure) → **DISTILL** (extract patterns) → **CONSOLIDATE** (persist)
|
|
294
294
|
|
|
295
295
|
- **ONNX Embeddings**: all-MiniLM-L6-v2, 384-dim
|
|
296
|
-
- **HNSW
|
|
296
|
+
- **HNSW**: faster than brute-force search, magnitude scales with corpus size — reproduce with \`node scripts/benchmark-intelligence.mjs --only=hnsw\` rather than a hardcoded multiplier
|
|
297
297
|
- **SONA**: Sub-millisecond pattern adaptation
|
|
298
298
|
- **Claude Bridge**: Auto-imports \`~/.claude/projects/*/memory/*.md\` into AgentDB`;
|
|
299
299
|
}
|
package/dist/src/mcp-server.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as os from 'os';
|
|
|
26
26
|
import { fileURLToPath } from 'url';
|
|
27
27
|
import { dirname } from 'path';
|
|
28
28
|
import { trackRequest } from './mcp-tools/request-tracker.js';
|
|
29
|
+
import { isPolicyEnforcementEnabled, loadMcpPolicy, evaluateToolCall, } from './mcp-tools/policy-enforcer.js';
|
|
29
30
|
// ESM-compatible __dirname
|
|
30
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
32
|
const __dirname = dirname(__filename);
|
|
@@ -503,6 +504,17 @@ export class MCPServerManager extends EventEmitter {
|
|
|
503
504
|
error: { code: -32601, message: `Tool not found: ${toolName}` },
|
|
504
505
|
};
|
|
505
506
|
}
|
|
507
|
+
if (isPolicyEnforcementEnabled()) {
|
|
508
|
+
const check = evaluateToolCall(loadMcpPolicy(), sessionId, toolName);
|
|
509
|
+
if (!check.allowed) {
|
|
510
|
+
trackRequest(toolName, false);
|
|
511
|
+
return {
|
|
512
|
+
jsonrpc: '2.0',
|
|
513
|
+
id: message.id,
|
|
514
|
+
error: { code: -32001, message: `Policy denied: ${check.reason}` },
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
506
518
|
try {
|
|
507
519
|
const result = await callMCPTool(toolName, toolParams, { sessionId });
|
|
508
520
|
trackRequest(toolName, true);
|
|
@@ -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 {
|
|
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);
|
|
@@ -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
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
import * as fs from 'fs';
|
|
60
|
+
import * as path from 'path';
|
|
61
|
+
import * as os from 'os';
|
|
62
|
+
export function isPolicyEnforcementEnabled(env = process.env) {
|
|
63
|
+
const v = env.RUFLO_MCP_ENFORCE_POLICY;
|
|
64
|
+
return v === '1' || (v ?? '').toLowerCase() === 'true';
|
|
65
|
+
}
|
|
66
|
+
export function loadMcpPolicy(policyPath = path.join(process.cwd(), '.harness', 'mcp-policy.json')) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = fs.readFileSync(policyPath, 'utf-8');
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
71
|
+
return null;
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_TURN_WINDOW_MS = 60_000;
|
|
79
|
+
const sessionState = new Map();
|
|
80
|
+
/** Test-only: clear per-session call state between test cases. */
|
|
81
|
+
export function resetPolicyEnforcerState() {
|
|
82
|
+
sessionState.clear();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Checks (and, if allowed, records) a tool call against
|
|
86
|
+
* `policy.maxToolCallsPerTurn`, counted over a sliding window of
|
|
87
|
+
* `policy.turnWindowMs` (default 60000ms) rather than the session's whole
|
|
88
|
+
* lifetime. Calls older than the window are pruned before comparing count
|
|
89
|
+
* to limit, so a session that pauses gets its budget back rather than
|
|
90
|
+
* staying denied until the process restarts. `now` defaults to `Date.now`
|
|
91
|
+
* for production callers; tests inject a controlled clock instead.
|
|
92
|
+
*/
|
|
93
|
+
export function checkAndRecordCall(policy, sessionId, now = Date.now()) {
|
|
94
|
+
const limit = policy.maxToolCallsPerTurn;
|
|
95
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) {
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
const configuredWindow = policy.turnWindowMs;
|
|
99
|
+
const windowMs = typeof configuredWindow === 'number' && Number.isFinite(configuredWindow) && configuredWindow > 0
|
|
100
|
+
? configuredWindow
|
|
101
|
+
: DEFAULT_TURN_WINDOW_MS;
|
|
102
|
+
const state = sessionState.get(sessionId) ?? { callTimes: [] };
|
|
103
|
+
const cutoff = now - windowMs;
|
|
104
|
+
state.callTimes = state.callTimes.filter((t) => t > cutoff);
|
|
105
|
+
if (state.callTimes.length >= limit) {
|
|
106
|
+
sessionState.set(sessionId, state);
|
|
107
|
+
return {
|
|
108
|
+
allowed: false,
|
|
109
|
+
reason: `maxToolCallsPerTurn (${limit}) exceeded within the last ${windowMs}ms for this session`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
state.callTimes.push(now);
|
|
113
|
+
sessionState.set(sessionId, state);
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
let auditLogPathOverride = null;
|
|
117
|
+
/** Test-only: redirect the audit log to a temp file instead of the default path. */
|
|
118
|
+
export function setAuditLogPathForTesting(p) {
|
|
119
|
+
auditLogPathOverride = p;
|
|
120
|
+
}
|
|
121
|
+
function defaultAuditLogPath() {
|
|
122
|
+
return path.join(os.tmpdir(), 'ruflo-mcp-audit.jsonl');
|
|
123
|
+
}
|
|
124
|
+
export function getAuditLogPath() {
|
|
125
|
+
return auditLogPathOverride ?? defaultAuditLogPath();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Appends one JSONL audit record. Returns `true` if `policy.auditLog` is not
|
|
129
|
+
* set (nothing was required) or the write succeeded; `false` only when
|
|
130
|
+
* `auditLog` is required and the write itself failed (disk full, unwritable
|
|
131
|
+
* path, etc). Never throws — the caller (`evaluateToolCall`) decides what a
|
|
132
|
+
* failed *mandatory* write means for the call (fail-closed: deny it).
|
|
133
|
+
*/
|
|
134
|
+
export function appendAuditLog(policy, entry) {
|
|
135
|
+
if (!policy.auditLog)
|
|
136
|
+
return true;
|
|
137
|
+
try {
|
|
138
|
+
fs.appendFileSync(getAuditLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Single enforcement entry point for a `tools/call` dispatch. Combines, in
|
|
147
|
+
* order: fail-closed on a missing/malformed policy, the per-session call
|
|
148
|
+
* budget, and fail-closed on a failed mandatory audit-log write. `policy`
|
|
149
|
+
* is the result of `loadMcpPolicy()` — pass `null` straight through when it
|
|
150
|
+
* failed to load, rather than re-deciding that here.
|
|
151
|
+
*/
|
|
152
|
+
export function evaluateToolCall(policy, sessionId, toolName, now = Date.now()) {
|
|
153
|
+
if (policy === null) {
|
|
154
|
+
return {
|
|
155
|
+
allowed: false,
|
|
156
|
+
reason: 'RUFLO_MCP_ENFORCE_POLICY is set but .harness/mcp-policy.json is missing or invalid — failing closed',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const budget = checkAndRecordCall(policy, sessionId, now);
|
|
160
|
+
const auditOk = appendAuditLog(policy, {
|
|
161
|
+
timestamp: new Date(now).toISOString(),
|
|
162
|
+
sessionId,
|
|
163
|
+
toolName,
|
|
164
|
+
allowed: budget.allowed,
|
|
165
|
+
reason: budget.reason,
|
|
166
|
+
});
|
|
167
|
+
if (!budget.allowed)
|
|
168
|
+
return budget;
|
|
169
|
+
if (!auditOk) {
|
|
170
|
+
return {
|
|
171
|
+
allowed: false,
|
|
172
|
+
reason: 'audit log write failed and policy.auditLog is required — failing closed',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { allowed: true };
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=policy-enforcer.js.map
|
|
@@ -178,13 +178,25 @@ declare class LocalReasoningBank {
|
|
|
178
178
|
*/
|
|
179
179
|
store(pattern: Omit<StoredPattern, 'usageCount' | 'createdAt' | 'lastUsedAt'> & Partial<StoredPattern>): void;
|
|
180
180
|
/**
|
|
181
|
-
* Find similar patterns by embedding
|
|
181
|
+
* Find similar patterns by embedding.
|
|
182
|
+
*
|
|
183
|
+
* `confidence` on each result is the pattern's own learned reliability
|
|
184
|
+
* (unchanged from storage) — NOT how well it matches this query. The
|
|
185
|
+
* per-query cosine score is returned separately as `similarity`. Callers
|
|
186
|
+
* that want "how good a semantic match is this" must read `.similarity`;
|
|
187
|
+
* callers that want "how reliable has this pattern proven to be" read
|
|
188
|
+
* `.confidence`. Prior to this fix both were conflated (confidence was
|
|
189
|
+
* overwritten with the cosine score), which silently broke any consumer
|
|
190
|
+
* that needed to tell them apart (found during the 2026-09-12 dream-cycle
|
|
191
|
+
* intelligence-surface review).
|
|
182
192
|
*/
|
|
183
193
|
findSimilar(queryEmbedding: number[], options: {
|
|
184
194
|
k?: number;
|
|
185
195
|
threshold?: number;
|
|
186
196
|
type?: string;
|
|
187
|
-
}): StoredPattern
|
|
197
|
+
}): (StoredPattern & {
|
|
198
|
+
similarity: number;
|
|
199
|
+
})[];
|
|
188
200
|
/**
|
|
189
201
|
* Optimized cosine similarity
|
|
190
202
|
*/
|