claude-flow 3.54.0 → 3.55.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 +65 -3
- package/.claude/proven-config.json +42 -0
- package/node_modules/@claude-flow/mcp/dist/connection-pool.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/connection-pool.js +5 -2
- package/node_modules/@claude-flow/mcp/dist/connection-pool.js.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts +7 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.js +60 -47
- package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
- package/node_modules/@claude-flow/mcp/package.json +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts +3 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js +4 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +11 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.js +19 -3
- package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts +4 -2
- 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 +5 -5
- package/v3/@claude-flow/cli/bin/cli.js +1 -1
- package/v3/@claude-flow/cli/bin/mcp-server.js +1 -1
- package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
- package/v3/@claude-flow/cli/dist/src/blocking-stdio.d.ts +11 -0
- package/v3/@claude-flow/cli/dist/src/blocking-stdio.js +23 -0
- package/v3/@claude-flow/cli/dist/src/commands/autopilot.js +2 -2
- package/v3/@claude-flow/cli/dist/src/commands/daemon.js +21 -1
- package/v3/@claude-flow/cli/dist/src/commands/hive-mind.js +7 -0
- package/v3/@claude-flow/cli/dist/src/commands/mcp.js +35 -1
- package/v3/@claude-flow/cli/dist/src/commands/policy.js +10 -2
- package/v3/@claude-flow/cli/dist/src/commands/session.js +11 -37
- package/v3/@claude-flow/cli/dist/src/index.js +3 -0
- package/v3/@claude-flow/cli/dist/src/init/executor.js +1 -0
- package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +65 -3
- package/v3/@claude-flow/cli/dist/src/mcp-server.d.ts +33 -0
- package/v3/@claude-flow/cli/dist/src/mcp-server.js +96 -4
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentdb-tools.js +13 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.d.ts +2 -4
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.js +214 -40
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +1 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +4 -1
- package/v3/@claude-flow/cli/dist/src/memory/graph-edge-writer.js +3 -2
- package/v3/@claude-flow/cli/dist/src/memory/lucene-bm25.js +11 -19
- package/v3/@claude-flow/cli/dist/src/memory/memory-bridge.d.ts +1 -0
- package/v3/@claude-flow/cli/dist/src/memory/memory-bridge.js +14 -1
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +5 -4
- package/v3/@claude-flow/cli/dist/src/memory/shared-sqlite.d.ts +7 -0
- package/v3/@claude-flow/cli/dist/src/memory/shared-sqlite.js +45 -0
- package/v3/@claude-flow/cli/dist/src/mods/probe.d.ts +7 -0
- package/v3/@claude-flow/cli/dist/src/mods/probe.js +29 -1
- package/v3/@claude-flow/cli/dist/src/parser.d.ts +3 -0
- package/v3/@claude-flow/cli/dist/src/parser.js +64 -13
- package/v3/@claude-flow/cli/dist/src/production/monitoring.d.ts +2 -0
- package/v3/@claude-flow/cli/dist/src/production/monitoring.js +8 -3
- package/v3/@claude-flow/cli/dist/src/production/rate-limiter.js +8 -5
- package/v3/@claude-flow/cli/dist/src/production/retry.js +3 -1
- package/v3/@claude-flow/cli/dist/src/services/bounded-worker-pool.js +3 -0
- package/v3/@claude-flow/cli/dist/src/services/config-file-manager.d.ts +3 -0
- package/v3/@claude-flow/cli/dist/src/services/config-file-manager.js +24 -7
- package/v3/@claude-flow/cli/dist/src/services/git-workspace-identity.js +20 -3
- package/v3/@claude-flow/cli/dist/src/services/policy-ledger-anchor.d.ts +42 -0
- package/v3/@claude-flow/cli/dist/src/services/policy-ledger-anchor.js +163 -0
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +11 -5
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +70 -15
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +1 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +12 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-queue.d.ts +1 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-queue.js +20 -3
- package/v3/@claude-flow/cli/package.json +7 -7
- package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.d.ts +2 -0
- package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.js +32 -0
- package/v3/@claude-flow/shared/dist/events/event-store.js +11 -4
- package/v3/@claude-flow/shared/dist/events/event-store.test.js +20 -0
- package/v3/@claude-flow/shared/dist/mcp/connection-pool.js +5 -2
- package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.d.ts +2 -0
- package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.js +33 -0
- package/v3/@claude-flow/shared/dist/resilience/bulkhead.js +2 -1
- package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.d.ts +2 -0
- package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.js +38 -0
- package/v3/@claude-flow/shared/dist/resilience/retry.js +9 -2
- package/v3/@claude-flow/shared/package.json +1 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { output } from '../output.js';
|
|
9
9
|
import { confirm } from '../prompt.js';
|
|
10
10
|
import { installParentDeathWatchdog } from '../runtime/parent-death-watchdog.js';
|
|
11
|
-
import { getServerManager, getMCPServerStatus, filterAdvertisedMcpTools, parseMcpToolSelection, } from '../mcp-server.js';
|
|
11
|
+
import { getServerManager, getMCPServerStatus, resolveMcpHttpAuthToken, isLoopbackHost, filterAdvertisedMcpTools, parseMcpToolSelection, } from '../mcp-server.js';
|
|
12
12
|
import { listMCPTools, callMCPTool, hasTool } from '../mcp-client.js';
|
|
13
13
|
// MCP tools categories
|
|
14
14
|
const TOOL_CATEGORIES = [
|
|
@@ -71,6 +71,16 @@ const startCommand = {
|
|
|
71
71
|
description: 'HTTP request timeout in milliseconds (default 30000; env RUFLO_MCP_REQUEST_TIMEOUT_MS)',
|
|
72
72
|
type: 'number'
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: 'auth-token-file',
|
|
76
|
+
description: 'Read the HTTP bearer token from this file (http transport). Preferred over --auth-token; env RUFLO_MCP_HTTP_TOKEN also works',
|
|
77
|
+
type: 'string'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'auth-token',
|
|
81
|
+
description: 'HTTP bearer token (16+ printable ASCII chars). Visible in argv/ps - prefer RUFLO_MCP_HTTP_TOKEN or --auth-token-file',
|
|
82
|
+
type: 'string'
|
|
83
|
+
},
|
|
74
84
|
{
|
|
75
85
|
name: 'daemon',
|
|
76
86
|
short: 'd',
|
|
@@ -90,6 +100,8 @@ const startCommand = {
|
|
|
90
100
|
{ command: 'claude-flow mcp start', description: 'Start with defaults (stdio)' },
|
|
91
101
|
{ command: 'claude-flow mcp start -p 8080 -t http', description: 'Start HTTP server' },
|
|
92
102
|
{ command: 'claude-flow mcp start -t http --request-timeout-ms 120000', description: 'Allow slower cold-start HTTP tools' },
|
|
103
|
+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http', description: 'HTTP server requiring "Authorization: Bearer <token>" on every request (except GET /health)' },
|
|
104
|
+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http --host 0.0.0.0', description: 'Non-loopback bind: allowed only with a token (or RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1)' },
|
|
93
105
|
{ command: 'claude-flow mcp start -d', description: 'Start as daemon' },
|
|
94
106
|
{ command: 'claude-flow mcp start -f', description: 'Force restart (kill existing)' }
|
|
95
107
|
],
|
|
@@ -109,6 +121,17 @@ const startCommand = {
|
|
|
109
121
|
output.printError('Request timeout must be an integer from 1 to 3600000 milliseconds');
|
|
110
122
|
return { success: false, exitCode: 1 };
|
|
111
123
|
}
|
|
124
|
+
let authToken;
|
|
125
|
+
try {
|
|
126
|
+
authToken = resolveMcpHttpAuthToken({
|
|
127
|
+
token: (ctx.flags.authToken ?? ctx.flags['auth-token']),
|
|
128
|
+
tokenFile: (ctx.flags.authTokenFile ?? ctx.flags['auth-token-file']),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
output.printError(error.message);
|
|
133
|
+
return { success: false, exitCode: 1 };
|
|
134
|
+
}
|
|
112
135
|
const daemon = ctx.flags.daemon ?? false;
|
|
113
136
|
const force = ctx.flags.force ?? false;
|
|
114
137
|
output.writeln();
|
|
@@ -166,6 +189,7 @@ const startCommand = {
|
|
|
166
189
|
port,
|
|
167
190
|
tools: !tools || tools === 'all' ? 'all' : tools.split(','),
|
|
168
191
|
...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }),
|
|
192
|
+
...(authToken ? { authToken } : {}),
|
|
169
193
|
daemonize: daemon,
|
|
170
194
|
};
|
|
171
195
|
try {
|
|
@@ -218,6 +242,15 @@ const startCommand = {
|
|
|
218
242
|
if (transport === 'http') {
|
|
219
243
|
output.writeln(output.dim(` Health: http://${host}:${port}/health`));
|
|
220
244
|
output.writeln(output.dim(` RPC: http://${host}:${port}/rpc`));
|
|
245
|
+
if (authToken) {
|
|
246
|
+
output.writeln(output.dim(' Auth: bearer token required (Authorization: Bearer <token>); /health is public'));
|
|
247
|
+
}
|
|
248
|
+
else if (isLoopbackHost(host)) {
|
|
249
|
+
output.printWarning('No auth token set: any local process (and DNS-rebinding web pages) can call tools. Set RUFLO_MCP_HTTP_TOKEN to require a bearer token.');
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
output.printWarning('Running UNAUTHENTICATED on a non-loopback host (RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP is set).');
|
|
253
|
+
}
|
|
221
254
|
}
|
|
222
255
|
else if (transport === 'websocket') {
|
|
223
256
|
output.writeln(output.dim(` WebSocket: ws://${host}:${port}/ws`));
|
|
@@ -596,6 +629,7 @@ const execCommand = {
|
|
|
596
629
|
const startTime = performance.now();
|
|
597
630
|
const result = await callMCPTool(tool, params, {
|
|
598
631
|
sessionId: `cli-${Date.now().toString(36)}`,
|
|
632
|
+
transport: 'cli',
|
|
599
633
|
requestId: `exec-${Date.now()}`,
|
|
600
634
|
});
|
|
601
635
|
const duration = performance.now() - startTime;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { anchorPosture } from '../services/policy-ledger-anchor.js';
|
|
1
2
|
import { output } from '../output.js';
|
|
2
3
|
import { autoMigratePolicyStateIfNeeded, evaluatePolicyRequest, loadPolicyState, revokePolicyApproval, setPolicyBudget, setPolicyMode, upsertPolicyRule, verifyPolicyLedger, } from '../services/policy-runtime.js';
|
|
3
4
|
function argJson(value, label) {
|
|
@@ -19,12 +20,16 @@ function requireInteractiveAdministrator() {
|
|
|
19
20
|
throw new Error('policy administration requires an interactive local terminal');
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
// #3602: say what verify can and cannot prove.
|
|
24
|
+
const LEDGER_TRUST_SCOPE = 'detects edits confined to state.json or to the project directory; '
|
|
25
|
+
+ 'does not defend against an attacker who can rewrite both the project directory and ~/.config/ruflo (ADR-475)';
|
|
22
26
|
export const policyCommand = {
|
|
23
27
|
name: 'policy',
|
|
24
28
|
description: 'Agentic policy engine — evaluate actions, manage rules/approvals, and verify the decision ledger (ADR-324)',
|
|
25
29
|
options: [
|
|
26
30
|
{ name: 'mode', type: 'string', description: 'Policy mode: legacy | observe | enforce' },
|
|
27
31
|
{ name: 'project-root', type: 'string', description: 'Project root containing .claude-flow/policy' },
|
|
32
|
+
{ name: 'establish-anchor', type: 'boolean', description: 'verify: explicitly anchor a ledger that has receipts but no anchor (interactive, logged with user and time)' },
|
|
28
33
|
],
|
|
29
34
|
async action(context) {
|
|
30
35
|
const args = context.args ?? [];
|
|
@@ -87,8 +92,11 @@ export const policyCommand = {
|
|
|
87
92
|
return print({ receipts: state.receipts });
|
|
88
93
|
}
|
|
89
94
|
if (operation === 'verify') {
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const establishAnchor = flags['establish-anchor'] === true || flags.establishAnchor === true;
|
|
96
|
+
if (establishAnchor)
|
|
97
|
+
requireInteractiveAdministrator();
|
|
98
|
+
const ledger = await verifyPolicyLedger(root, { establishAnchor });
|
|
99
|
+
const result = print({ ...ledger, anchors: anchorPosture(root), scope: LEDGER_TRUST_SCOPE });
|
|
92
100
|
return ledger.valid ? result : { ...result, success: false, exitCode: 1 };
|
|
93
101
|
}
|
|
94
102
|
throw new Error(`unknown policy operation: ${operation}`);
|
|
@@ -7,6 +7,8 @@ import { confirm, input, select } from '../prompt.js';
|
|
|
7
7
|
import { callMCPTool, MCPClientError } from '../mcp-client.js';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
11
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
10
12
|
// Format date for display
|
|
11
13
|
function formatDate(dateStr) {
|
|
12
14
|
if (!dateStr)
|
|
@@ -561,7 +563,7 @@ const exportCommand = {
|
|
|
561
563
|
// Format output
|
|
562
564
|
let content;
|
|
563
565
|
if (exportFormat === 'yaml') {
|
|
564
|
-
content =
|
|
566
|
+
content = stringifyYaml(result.data);
|
|
565
567
|
}
|
|
566
568
|
else {
|
|
567
569
|
content = JSON.stringify(result.data, null, 2);
|
|
@@ -570,7 +572,8 @@ const exportCommand = {
|
|
|
570
572
|
const absolutePath = path.isAbsolute(outputPath)
|
|
571
573
|
? outputPath
|
|
572
574
|
: path.join(ctx.cwd, outputPath);
|
|
573
|
-
|
|
575
|
+
const bytes = compress ? gzipSync(content) : Buffer.from(content, 'utf-8');
|
|
576
|
+
fs.writeFileSync(absolutePath, bytes);
|
|
574
577
|
spinner.succeed('Session exported');
|
|
575
578
|
output.writeln();
|
|
576
579
|
// session_export returns the whole record under `data`; its stats live there.
|
|
@@ -587,14 +590,14 @@ const exportCommand = {
|
|
|
587
590
|
{ property: 'Agents', value: exportStats.agentCount ?? exportStats.agents ?? 0 },
|
|
588
591
|
{ property: 'Tasks', value: exportStats.taskCount ?? exportStats.tasks ?? 0 },
|
|
589
592
|
{ property: 'Memory Entries', value: exportStats.memoryEntries ?? 0 },
|
|
590
|
-
{ property: 'File Size', value: formatSize(
|
|
593
|
+
{ property: 'File Size', value: formatSize(bytes.length) }
|
|
591
594
|
]
|
|
592
595
|
});
|
|
593
596
|
output.writeln();
|
|
594
597
|
output.printSuccess(`Session exported to ${outputPath}`);
|
|
595
598
|
return {
|
|
596
599
|
success: true,
|
|
597
|
-
data: { sessionId, outputPath, format: exportFormat, size:
|
|
600
|
+
data: { sessionId, outputPath, format: exportFormat, size: bytes.length }
|
|
598
601
|
};
|
|
599
602
|
}
|
|
600
603
|
catch (error) {
|
|
@@ -647,9 +650,11 @@ const importCommand = {
|
|
|
647
650
|
try {
|
|
648
651
|
// The YAML written by `session export --format yaml` is display-only;
|
|
649
652
|
// there is no YAML parser here, so say so instead of failing obscurely.
|
|
650
|
-
if (
|
|
653
|
+
if (/\.ya?ml(?:\.gz)?$/i.test(absolutePath)) {
|
|
651
654
|
try {
|
|
652
|
-
|
|
655
|
+
const bytes = fs.readFileSync(absolutePath);
|
|
656
|
+
const content = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes;
|
|
657
|
+
JSON.parse(content.toString('utf-8'));
|
|
653
658
|
}
|
|
654
659
|
catch {
|
|
655
660
|
spinner.fail('Failed to import session');
|
|
@@ -783,37 +788,6 @@ function formatDuration(ms) {
|
|
|
783
788
|
}
|
|
784
789
|
return `${seconds}s`;
|
|
785
790
|
}
|
|
786
|
-
function toSimpleYaml(obj, indent = 0) {
|
|
787
|
-
// Simple YAML serializer (for basic types)
|
|
788
|
-
if (obj === null)
|
|
789
|
-
return 'null';
|
|
790
|
-
if (typeof obj === 'boolean')
|
|
791
|
-
return String(obj);
|
|
792
|
-
if (typeof obj === 'number')
|
|
793
|
-
return String(obj);
|
|
794
|
-
if (typeof obj === 'string')
|
|
795
|
-
return obj.includes(':') ? `"${obj}"` : obj;
|
|
796
|
-
const spaces = ' '.repeat(indent);
|
|
797
|
-
let result = '';
|
|
798
|
-
if (Array.isArray(obj)) {
|
|
799
|
-
for (const item of obj) {
|
|
800
|
-
result += `${spaces}- ${toSimpleYaml(item, indent + 1).trim()}\n`;
|
|
801
|
-
}
|
|
802
|
-
return result;
|
|
803
|
-
}
|
|
804
|
-
if (typeof obj === 'object') {
|
|
805
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
806
|
-
if (typeof value === 'object' && value !== null) {
|
|
807
|
-
result += `${spaces}${key}:\n${toSimpleYaml(value, indent + 1)}`;
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
result += `${spaces}${key}: ${toSimpleYaml(value, indent)}\n`;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
return result;
|
|
814
|
-
}
|
|
815
|
-
return String(obj);
|
|
816
|
-
}
|
|
817
791
|
// Main session command
|
|
818
792
|
export const sessionCommand = {
|
|
819
793
|
name: 'session',
|
|
@@ -19,6 +19,7 @@ import { output } from './output.js';
|
|
|
19
19
|
import { commands, getCommandsByCategory, getCommand, getCommandAsync, getCommandNames, getLazyCommandNames, hasCommand } from './commands/index.js';
|
|
20
20
|
import { suggestCommand } from './suggest.js';
|
|
21
21
|
import { runStartupUpdateCheck } from './update/index.js';
|
|
22
|
+
import { ensureBlockingStdio } from './blocking-stdio.js';
|
|
22
23
|
// Read version from package.json at runtime
|
|
23
24
|
function getPackageVersion() {
|
|
24
25
|
try {
|
|
@@ -66,6 +67,8 @@ export class CLI {
|
|
|
66
67
|
* Run the CLI with given arguments
|
|
67
68
|
*/
|
|
68
69
|
async run(args = process.argv.slice(2)) {
|
|
70
|
+
// #3851: keep piped output intact across the process.exit() calls below
|
|
71
|
+
ensureBlockingStdio();
|
|
69
72
|
try {
|
|
70
73
|
// #1791.2 — If the user invoked a lazy command (e.g. `hive-mind task`),
|
|
71
74
|
// pre-load it BEFORE parsing so the parser can build scoped flag
|
|
@@ -436,6 +436,7 @@ module.exports = commands;
|
|
|
436
436
|
// Literal-word root guard mirrored in the mod and both shipped classic helpers.
|
|
437
437
|
const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, depth = 0) {
|
|
438
438
|
let word = '', quote = '', started = false, redirect = false
|
|
439
|
+
let ansiNul = false // a NUL inside $'...' ends that string, as in bash
|
|
439
440
|
let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
|
|
440
441
|
const isRoot = (operand) => {
|
|
441
442
|
if (!operand.startsWith('/')) return false
|
|
@@ -447,22 +448,55 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
447
448
|
}
|
|
448
449
|
return parts.length === 0 || /[*?\[]/.test(parts[0])
|
|
449
450
|
}
|
|
451
|
+
// One backslash escape inside $'...', from the character after the backslash.
|
|
452
|
+
// Returns the text it stands for and the index of its last character. An unknown escape keeps its backslash.
|
|
453
|
+
// The command arrives lowercased, so an uppercase-U escape reads as a lowercase one: eight digits starting 0000 are one code point.
|
|
454
|
+
const ansiEscape = (s, at) => {
|
|
455
|
+
const c = s[at]
|
|
456
|
+
const simple = { a: '\x07', b: '\b', e: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
|
|
457
|
+
if (simple[c] !== undefined) return [simple[c], at]
|
|
458
|
+
const digits = (from, max, pattern) => pattern.exec(s.slice(from, from + max))?.[0]
|
|
459
|
+
const octal = digits(at, 3, /^[0-7]+/)
|
|
460
|
+
if (octal) return [String.fromCharCode(parseInt(octal, 8) & 255), at + octal.length - 1]
|
|
461
|
+
const hex = c === 'x' ? digits(at + 1, 2, /^[0-9a-f]+/) : undefined
|
|
462
|
+
if (hex) return [String.fromCharCode(parseInt(hex, 16)), at + hex.length]
|
|
463
|
+
const wide = c === 'u' ? (digits(at + 1, 8, /^0000[0-9a-f]{4}$/) ?? digits(at + 1, 4, /^[0-9a-f]+/)) : undefined
|
|
464
|
+
if (wide) return [String.fromCodePoint(parseInt(wide, 16)), at + wide.length]
|
|
465
|
+
if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
|
|
466
|
+
return ['\\' + c, at]
|
|
467
|
+
}
|
|
450
468
|
const finishWord = () => {
|
|
451
469
|
if (!started) return false
|
|
452
470
|
// Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
|
|
453
471
|
// Bound rescanning to four levels; beyond that retain its conservative check.
|
|
454
|
-
if (
|
|
472
|
+
if (/[\s;&|()\x60]/.test(word)) {
|
|
455
473
|
if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
|
|
456
474
|
}
|
|
457
475
|
if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
|
|
458
476
|
else if (!optionsEnded && word === '--') optionsEnded = true
|
|
459
477
|
else if (!optionsEnded && word.startsWith('-')) {
|
|
460
|
-
|
|
461
|
-
|
|
478
|
+
// GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
|
|
479
|
+
recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
|
|
480
|
+
force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
|
|
462
481
|
} else root = root || isRoot(word)
|
|
463
482
|
word = ''; started = false
|
|
464
483
|
return inRm && recursive && force && root
|
|
465
484
|
}
|
|
485
|
+
// A command substitution's body, from just after its opening backtick. Bash removes a
|
|
486
|
+
// backslash only before $, a backtick or a backslash (and " inside double quotes) and
|
|
487
|
+
// drops backslash-newline; any other backslash stays for the nested scan.
|
|
488
|
+
const substitution = (from, inDouble) => {
|
|
489
|
+
let body = '', j = from
|
|
490
|
+
for (; j < command.length && command[j] !== '\x60'; j++) {
|
|
491
|
+
const next = command[j + 1]
|
|
492
|
+
if (command[j] === '\\' && j + 1 < command.length) {
|
|
493
|
+
if (next === '\n') { j++; continue }
|
|
494
|
+
if (next === '$' || next === '\x60' || next === '\\' || (inDouble && next === '"')) { body += next; j++; continue }
|
|
495
|
+
}
|
|
496
|
+
body += command[j]
|
|
497
|
+
}
|
|
498
|
+
return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
|
|
499
|
+
}
|
|
466
500
|
const finishCommand = () => {
|
|
467
501
|
const denied = inRm && recursive && force && root
|
|
468
502
|
inRm = optionsEnded = recursive = force = root = false
|
|
@@ -473,7 +507,25 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
473
507
|
const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
|
|
474
508
|
redirect = false
|
|
475
509
|
if (quote) {
|
|
510
|
+
if (quote === "$'") {
|
|
511
|
+
if (char === "'") { quote = ''; ansiNul = false }
|
|
512
|
+
else if (char === '\\' && i + 1 < command.length) {
|
|
513
|
+
const [text, last] = ansiEscape(command, i + 1)
|
|
514
|
+
i = last
|
|
515
|
+
if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
|
|
516
|
+
} else if (!ansiNul) word += char
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
476
519
|
if (char === quote) quote = ''
|
|
520
|
+
else if (quote === '"' && char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
521
|
+
const [end, denied] = substitution(i + 1, true)
|
|
522
|
+
if (denied) return true
|
|
523
|
+
// The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
|
|
524
|
+
// A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
|
|
525
|
+
// from reading it as the start of a comment.
|
|
526
|
+
if (command[end + 1] === '#') word += '\u0001'
|
|
527
|
+
i = end
|
|
528
|
+
}
|
|
477
529
|
else if (quote === '"' && char === '\\' && i + 1 < command.length &&
|
|
478
530
|
(command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
|
|
479
531
|
command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
|
|
@@ -485,6 +537,16 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
485
537
|
if (char === '\\' && i + 1 < command.length) {
|
|
486
538
|
const next = command[++i]
|
|
487
539
|
if (next !== '\n') { word += next; started = true }
|
|
540
|
+
} else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
541
|
+
// Command substitution: scan the body as its own command; the substitution
|
|
542
|
+
// stays inside the enclosing word, so the rm being parsed keeps its state,
|
|
543
|
+
// and the word has started (a # right after it is not a comment).
|
|
544
|
+
const [end, denied] = substitution(i + 1, false)
|
|
545
|
+
if (denied) return true
|
|
546
|
+
i = end; started = true
|
|
547
|
+
} else if (char === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) {
|
|
548
|
+
// ANSI-C ($'...': escapes decoded) and locale ($"...") quoting: the $ is not part of the word.
|
|
549
|
+
quote = command[i + 1] === "'" ? "$'" : '"'; started = true; i++
|
|
488
550
|
} else if (char === '"' || char === "'") {
|
|
489
551
|
quote = char; started = true
|
|
490
552
|
} else if (char === '#' && !started) {
|
|
@@ -30,6 +30,12 @@ export interface MCPServerOptions {
|
|
|
30
30
|
daemonize?: boolean;
|
|
31
31
|
timeout?: number;
|
|
32
32
|
requestTimeoutMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Bearer token every HTTP request must present (`Authorization: Bearer <token>`).
|
|
35
|
+
* http transport only. Prefer RUFLO_MCP_HTTP_TOKEN / a token file over this
|
|
36
|
+
* field's CLI flag so the secret stays out of argv. Never logged or emitted.
|
|
37
|
+
*/
|
|
38
|
+
authToken?: string;
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* MCP Server status
|
|
@@ -49,6 +55,33 @@ export interface MCPServerStatus {
|
|
|
49
55
|
metrics?: Record<string, number>;
|
|
50
56
|
};
|
|
51
57
|
}
|
|
58
|
+
/** Exported for tests — see LOOPBACK_HOSTS. */
|
|
59
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Explicit, documented opt-out of the non-loopback authorization gate in
|
|
62
|
+
* startHttpServer(). Exported for tests.
|
|
63
|
+
*/
|
|
64
|
+
export declare function isUnauthenticatedHttpAllowed(env?: NodeJS.ProcessEnv): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* The full startHttpServer() authorization gate, as a pure function:
|
|
67
|
+
* true means "refuse to start" (see the thrown error for why).
|
|
68
|
+
*
|
|
69
|
+
* A non-loopback bind is allowed when requests are authenticated (a bearer
|
|
70
|
+
* token is configured on the http transport) or the operator explicitly opts
|
|
71
|
+
* out with RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1. Loopback binds are never
|
|
72
|
+
* refused, with or without a token. Exported for tests.
|
|
73
|
+
*/
|
|
74
|
+
export declare function shouldRefuseUnauthenticatedHttp(host: string, env?: NodeJS.ProcessEnv, authenticated?: boolean): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the HTTP bearer token. Precedence: explicit value (CLI flag), token
|
|
77
|
+
* file, then RUFLO_MCP_HTTP_TOKEN. An unset/empty env var means "no token";
|
|
78
|
+
* a token that is set but malformed throws, so a typo can never silently
|
|
79
|
+
* start the server unauthenticated. Exported for tests.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveMcpHttpAuthToken(input?: {
|
|
82
|
+
token?: string;
|
|
83
|
+
tokenFile?: string;
|
|
84
|
+
}, env?: NodeJS.ProcessEnv, readFile?: (p: string) => string): string | undefined;
|
|
52
85
|
export declare function parseMcpToolSelection(value: string | undefined): string[] | 'all';
|
|
53
86
|
/**
|
|
54
87
|
* Apply the existing `--tools` contract to advertised schemas. A selector can
|
|
@@ -104,8 +104,72 @@ function processStartToken(pid) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
107
|
+
* Hosts treated as same-machine-only for MCP HTTP tool-call authorization
|
|
108
|
+
* purposes (see startHttpServer's non-loopback authorization gate below).
|
|
109
|
+
* Deliberately exact-string matching only (no DNS resolution) so anything
|
|
110
|
+
* not spelled exactly one of these three fails closed rather than open.
|
|
108
111
|
*/
|
|
112
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
113
|
+
/** Exported for tests — see LOOPBACK_HOSTS. */
|
|
114
|
+
export function isLoopbackHost(host) {
|
|
115
|
+
return LOOPBACK_HOSTS.has(host);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Explicit, documented opt-out of the non-loopback authorization gate in
|
|
119
|
+
* startHttpServer(). Exported for tests.
|
|
120
|
+
*/
|
|
121
|
+
export function isUnauthenticatedHttpAllowed(env = process.env) {
|
|
122
|
+
const v = env.RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP;
|
|
123
|
+
return v === '1' || v === 'true';
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The full startHttpServer() authorization gate, as a pure function:
|
|
127
|
+
* true means "refuse to start" (see the thrown error for why).
|
|
128
|
+
*
|
|
129
|
+
* A non-loopback bind is allowed when requests are authenticated (a bearer
|
|
130
|
+
* token is configured on the http transport) or the operator explicitly opts
|
|
131
|
+
* out with RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1. Loopback binds are never
|
|
132
|
+
* refused, with or without a token. Exported for tests.
|
|
133
|
+
*/
|
|
134
|
+
export function shouldRefuseUnauthenticatedHttp(host, env = process.env, authenticated = false) {
|
|
135
|
+
return !isLoopbackHost(host) && !authenticated && !isUnauthenticatedHttpAllowed(env);
|
|
136
|
+
}
|
|
137
|
+
/** Printable ASCII without space (RFC 6750 token-ish), 16-512 chars. */
|
|
138
|
+
const AUTH_TOKEN_PATTERN = /^[\x21-\x7e]{16,512}$/;
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the HTTP bearer token. Precedence: explicit value (CLI flag), token
|
|
141
|
+
* file, then RUFLO_MCP_HTTP_TOKEN. An unset/empty env var means "no token";
|
|
142
|
+
* a token that is set but malformed throws, so a typo can never silently
|
|
143
|
+
* start the server unauthenticated. Exported for tests.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveMcpHttpAuthToken(input = {}, env = process.env, readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
146
|
+
let raw;
|
|
147
|
+
let source = '';
|
|
148
|
+
if (input.token !== undefined) {
|
|
149
|
+
raw = input.token;
|
|
150
|
+
source = '--auth-token';
|
|
151
|
+
}
|
|
152
|
+
else if (input.tokenFile !== undefined) {
|
|
153
|
+
source = '--auth-token-file';
|
|
154
|
+
try {
|
|
155
|
+
raw = readFile(input.tokenFile).replace(/\r?\n$/, '');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new Error('Could not read the MCP HTTP auth token file given to --auth-token-file');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (env.RUFLO_MCP_HTTP_TOKEN !== undefined && env.RUFLO_MCP_HTTP_TOKEN !== '') {
|
|
162
|
+
raw = env.RUFLO_MCP_HTTP_TOKEN;
|
|
163
|
+
source = 'RUFLO_MCP_HTTP_TOKEN';
|
|
164
|
+
}
|
|
165
|
+
if (raw === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (!AUTH_TOKEN_PATTERN.test(raw)) {
|
|
168
|
+
// The value is deliberately not echoed.
|
|
169
|
+
throw new Error(`Invalid MCP HTTP auth token from ${source}: it must be 16-512 printable ASCII characters with no spaces`);
|
|
170
|
+
}
|
|
171
|
+
return raw;
|
|
172
|
+
}
|
|
109
173
|
const DEFAULT_OPTIONS = {
|
|
110
174
|
transport: 'stdio',
|
|
111
175
|
host: 'localhost',
|
|
@@ -239,7 +303,7 @@ export class MCPServerManager extends EventEmitter {
|
|
|
239
303
|
}
|
|
240
304
|
const startTime = performance.now();
|
|
241
305
|
this.startTime = new Date();
|
|
242
|
-
this.emit('starting', { options: this.options });
|
|
306
|
+
this.emit('starting', { options: { ...this.options, authToken: this.options.authToken ? '[redacted]' : undefined } });
|
|
243
307
|
try {
|
|
244
308
|
if (this.options.transport === 'stdio') {
|
|
245
309
|
// For stdio transport, spawn the server process
|
|
@@ -671,7 +735,7 @@ export class MCPServerManager extends EventEmitter {
|
|
|
671
735
|
}
|
|
672
736
|
}
|
|
673
737
|
try {
|
|
674
|
-
const result = await callMCPTool(toolName, toolParams, { sessionId });
|
|
738
|
+
const result = await callMCPTool(toolName, toolParams, { sessionId, transport: 'stdio' });
|
|
675
739
|
const isError = hasToolError(result);
|
|
676
740
|
trackRequest(toolName, !isError);
|
|
677
741
|
return {
|
|
@@ -725,6 +789,31 @@ export class MCPServerManager extends EventEmitter {
|
|
|
725
789
|
* Start HTTP server in-process
|
|
726
790
|
*/
|
|
727
791
|
async startHttpServer() {
|
|
792
|
+
// Authorization gate first, before any import or bind: a refused start must
|
|
793
|
+
// not depend on (or touch) anything else.
|
|
794
|
+
const authToken = this.options.authToken || undefined;
|
|
795
|
+
if (authToken && this.options.transport !== 'http') {
|
|
796
|
+
// The standalone websocket transport has no working token check, so a
|
|
797
|
+
// token there would be a false promise of protection.
|
|
798
|
+
throw new Error('An MCP HTTP auth token is only supported with --transport http');
|
|
799
|
+
}
|
|
800
|
+
if (shouldRefuseUnauthenticatedHttp(this.options.host, process.env, !!authToken)) {
|
|
801
|
+
// No ToolAuthorizer is wired below: @claude-flow/mcp's tool-call
|
|
802
|
+
// authorization is fully opt-in (requireToolAuthorization/toolAuthorizer),
|
|
803
|
+
// so without this check every registered tool (memory_*, hooks_*,
|
|
804
|
+
// agentdb_*, hive-mind_*, ...) would be callable by any client that can
|
|
805
|
+
// reach this host:port, unauthenticated — the same shape as
|
|
806
|
+
// CVE-2026-81735 (CVSS 10/10, UI-TARS-desktop mcp-http-server: optional
|
|
807
|
+
// auth middleware never wired by the integrating CLI + a non-loopback
|
|
808
|
+
// bind). Loopback stays unaffected; this only gates an explicit
|
|
809
|
+
// non-default `--host`. A configured bearer token (below) lifts the gate.
|
|
810
|
+
throw new Error(`Refusing to start the MCP HTTP server on non-loopback host "${this.options.host}": ` +
|
|
811
|
+
'this server is unauthenticated unless a token is set, so every registered MCP tool ' +
|
|
812
|
+
'would be reachable over the network. Set RUFLO_MCP_HTTP_TOKEN (or --auth-token-file) ' +
|
|
813
|
+
'to require a bearer token, bind to a loopback host (127.0.0.1, ::1, or localhost), or set ' +
|
|
814
|
+
'RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1 to acknowledge the risk and proceed ' +
|
|
815
|
+
'(e.g. when a trusted reverse proxy or network boundary already enforces auth).');
|
|
816
|
+
}
|
|
728
817
|
// Dynamically import the MCP server package
|
|
729
818
|
// FIX for issue #942: Use proper package import instead of broken relative path
|
|
730
819
|
const { createMCPServer } = await import('@claude-flow/mcp');
|
|
@@ -747,7 +836,7 @@ export class MCPServerManager extends EventEmitter {
|
|
|
747
836
|
cacheTTL: tool.cacheTTL,
|
|
748
837
|
handler: async (input, context) => {
|
|
749
838
|
try {
|
|
750
|
-
const result = await callMCPTool(tool.name, input || {}, { sessionId: context?.sessionId || fallbackSessionId });
|
|
839
|
+
const result = await callMCPTool(tool.name, input || {}, { sessionId: context?.sessionId || fallbackSessionId, transport: this.options.transport === 'websocket' ? 'websocket' : 'http' });
|
|
751
840
|
trackRequest(tool.name, !hasToolError(result));
|
|
752
841
|
return result;
|
|
753
842
|
}
|
|
@@ -770,6 +859,9 @@ export class MCPServerManager extends EventEmitter {
|
|
|
770
859
|
enableMetrics: true,
|
|
771
860
|
enableCaching: true,
|
|
772
861
|
requestTimeout: this.options.requestTimeoutMs,
|
|
862
|
+
auth: authToken
|
|
863
|
+
? { enabled: true, method: 'token', tokens: [authToken] }
|
|
864
|
+
: undefined,
|
|
773
865
|
}, logger);
|
|
774
866
|
const registration = mcpServer.registerTools(cliTools);
|
|
775
867
|
if (registration.failed.length > 0) {
|
|
@@ -150,6 +150,10 @@ export const agentdbPatternStore = {
|
|
|
150
150
|
const confidence = validateScore(params.confidence, 0.8);
|
|
151
151
|
const bridge = await getBridge();
|
|
152
152
|
const result = await bridge.bridgeStorePattern({ pattern, type, confidence });
|
|
153
|
+
if (result && result.success === false) {
|
|
154
|
+
// #3691: the write was refused/not persisted — never acknowledge it.
|
|
155
|
+
return { success: false, error: `Pattern store failed: ${result.error ?? 'write not persisted'}`, controller: result.controller };
|
|
156
|
+
}
|
|
153
157
|
if (result) {
|
|
154
158
|
// #3288: `controller: 'reasoningBank'` is the ONLY label that means
|
|
155
159
|
// the healthy path ran. Every other label bridgeStorePattern can
|
|
@@ -176,12 +180,20 @@ export const agentdbPatternStore = {
|
|
|
176
180
|
const { storeEntry } = await import('../memory/memory-initializer.js');
|
|
177
181
|
const patternId = `pattern-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
178
182
|
const value = JSON.stringify({ pattern, type, confidence, _fallback: 'reasoningBank-unavailable' });
|
|
179
|
-
await storeEntry({
|
|
183
|
+
const stored = await storeEntry({
|
|
180
184
|
key: patternId,
|
|
181
185
|
value,
|
|
182
186
|
namespace: 'pattern',
|
|
183
187
|
tags: [type, 'reasoning-pattern', 'fallback'],
|
|
184
188
|
});
|
|
189
|
+
// #3691: storeEntry reports failure as {success:false}, not a throw.
|
|
190
|
+
if (!stored || stored.success !== true) {
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error: `Pattern store failed: memory_store fallback did not persist (${stored?.error ?? 'no result'})`,
|
|
194
|
+
recommendation: 'Run agentdb_health to inspect controller registration and check that .swarm/memory.db is writable.',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
185
197
|
return {
|
|
186
198
|
success: true,
|
|
187
199
|
// #3288: a caller must not have to already know to distrust a
|
|
@@ -6,10 +6,8 @@
|
|
|
6
6
|
import { type MCPTool } from './types.js';
|
|
7
7
|
/**
|
|
8
8
|
* Read the current hiveToken directly off disk, for same-machine callers
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* tool response (in particular, not hive-mind_status), since that's a
|
|
12
|
-
* remote-reachable surface a capability token must not leak through.
|
|
9
|
+
* (the CLI's own `hive-mind join/leave/consensus` subcommands) -- NOT exposed
|
|
10
|
+
* over any MCP tool response.
|
|
13
11
|
*/
|
|
14
12
|
export declare function getHiveTokenForCli(): string | undefined;
|
|
15
13
|
export declare const hiveMindTools: MCPTool[];
|