claude-flow 3.54.1 → 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/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/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/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/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/missions/schemas.d.ts +125 -125
- package/v3/@claude-flow/cli/dist/src/mods/command-registry/schema.d.ts +199 -199
- 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/retry.js +1 -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/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.js +11 -2
- 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 +6 -2
- 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
|
@@ -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) {
|
|
@@ -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[];
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tool definitions for collective intelligence and swarm coordination.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, unlinkSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { getProjectCwd } from './types.js';
|
|
10
10
|
import { validateIdentifier, validateText } from './validate-input.js';
|
|
11
11
|
// Storage paths
|
|
@@ -84,32 +84,98 @@ function tryResolveProposal(proposal, totalNodes) {
|
|
|
84
84
|
}
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
|
+
const LOCAL_TRANSPORTS = new Set(['stdio', 'cli']);
|
|
88
|
+
const MIN_ENV_SECRET_LENGTH = 16;
|
|
89
|
+
const BOOTSTRAP_FILE = 'bootstrap.secret';
|
|
90
|
+
function classifyCaller(context) {
|
|
91
|
+
if (process.env.RUFLO_HIVE_REQUIRE_AUTH === '1')
|
|
92
|
+
return 'remote';
|
|
93
|
+
if (context === undefined || context === null)
|
|
94
|
+
return 'local';
|
|
95
|
+
const transport = context.transport;
|
|
96
|
+
return typeof transport === 'string' && LOCAL_TRANSPORTS.has(transport) ? 'local' : 'remote';
|
|
97
|
+
}
|
|
87
98
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
|
99
|
+
* Constant-time string equality. Both sides are hashed first so the compare is
|
|
100
|
+
* over fixed-length digests: no early exit and no length side channel.
|
|
92
101
|
*/
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
function constantTimeEqual(expected, supplied) {
|
|
103
|
+
const a = createHash('sha256').update(expected, 'utf-8').digest();
|
|
104
|
+
const b = createHash('sha256').update(supplied, 'utf-8').digest();
|
|
105
|
+
return timingSafeEqual(a, b);
|
|
106
|
+
}
|
|
107
|
+
function getBootstrapSecretPath() {
|
|
108
|
+
return join(getHiveDir(), BOOTSTRAP_FILE);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Operator secret: env `RUFLO_HIVE_BOOTSTRAP_SECRET` wins (>= 16 chars), else
|
|
112
|
+
* the 0600 file. Read-only: never creates anything.
|
|
113
|
+
*/
|
|
114
|
+
function readBootstrapSecret() {
|
|
115
|
+
const fromEnv = process.env.RUFLO_HIVE_BOOTSTRAP_SECRET?.trim();
|
|
116
|
+
if (fromEnv && fromEnv.length >= MIN_ENV_SECRET_LENGTH)
|
|
117
|
+
return fromEnv;
|
|
118
|
+
try {
|
|
119
|
+
const path = getBootstrapSecretPath();
|
|
120
|
+
if (existsSync(path)) {
|
|
121
|
+
const fromFile = readFileSync(path, 'utf-8').trim();
|
|
122
|
+
if (fromFile)
|
|
123
|
+
return fromFile;
|
|
124
|
+
}
|
|
96
125
|
}
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
catch {
|
|
127
|
+
// unreadable file == no secret configured
|
|
99
128
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create the 0600 secret file if neither env nor file provides one. Only local
|
|
133
|
+
* callers ever reach this (hive-mind_init, local path). Returns nothing: the
|
|
134
|
+
* value is never handed to a caller.
|
|
135
|
+
*/
|
|
136
|
+
function ensureBootstrapSecret() {
|
|
137
|
+
if (readBootstrapSecret())
|
|
138
|
+
return;
|
|
139
|
+
ensureHiveDir();
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(getBootstrapSecretPath(), randomBytes(32).toString('hex'), {
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
mode: 0o600,
|
|
144
|
+
flag: 'wx', // never clobber a secret another process just created
|
|
145
|
+
});
|
|
104
146
|
}
|
|
105
|
-
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err.code !== 'EEXIST')
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Authorize a caller for a gated hive-mind operation. Returns an error string
|
|
154
|
+
* on denial, null on success. Denial text never contains either credential.
|
|
155
|
+
*/
|
|
156
|
+
function authorizeHive(state, input, context, opts = {}) {
|
|
157
|
+
if (classifyCaller(context) === 'local')
|
|
158
|
+
return null;
|
|
159
|
+
const secret = readBootstrapSecret();
|
|
160
|
+
const suppliedSecret = typeof input.bootstrapSecret === 'string' ? input.bootstrapSecret : '';
|
|
161
|
+
if (secret && suppliedSecret && constantTimeEqual(secret, suppliedSecret))
|
|
162
|
+
return null;
|
|
163
|
+
const allowToken = opts.allowToken !== false;
|
|
164
|
+
const suppliedToken = typeof input.hiveToken === 'string' ? input.hiveToken : '';
|
|
165
|
+
if (allowToken && state?.hiveToken && suppliedToken && constantTimeEqual(state.hiveToken, suppliedToken)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (!suppliedSecret && !suppliedToken) {
|
|
169
|
+
return allowToken
|
|
170
|
+
? 'hiveToken is required (remote callers must present the operator credential: hiveToken or bootstrapSecret; local stdio/CLI callers are exempt; see ADR-476)'
|
|
171
|
+
: 'bootstrapSecret is required (remote callers must present the operator secret; set RUFLO_HIVE_BOOTSTRAP_SECRET on the server; see ADR-476)';
|
|
172
|
+
}
|
|
173
|
+
return suppliedSecret ? 'Invalid bootstrapSecret' : 'Invalid hiveToken';
|
|
106
174
|
}
|
|
107
175
|
/**
|
|
108
176
|
* Read the current hiveToken directly off disk, for same-machine callers
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* tool response (in particular, not hive-mind_status), since that's a
|
|
112
|
-
* remote-reachable surface a capability token must not leak through.
|
|
177
|
+
* (the CLI's own `hive-mind join/leave/consensus` subcommands) -- NOT exposed
|
|
178
|
+
* over any MCP tool response.
|
|
113
179
|
*/
|
|
114
180
|
export function getHiveTokenForCli() {
|
|
115
181
|
return loadHiveState().hiveToken;
|
|
@@ -123,8 +189,14 @@ function getHivePath() {
|
|
|
123
189
|
function ensureHiveDir() {
|
|
124
190
|
const dir = getHiveDir();
|
|
125
191
|
if (!existsSync(dir)) {
|
|
126
|
-
mkdirSync(dir, { recursive: true });
|
|
192
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
193
|
+
}
|
|
194
|
+
// The directory holds the capability token and operator secret: owner-only.
|
|
195
|
+
// Best effort (no-op on Windows, may fail on a directory we do not own).
|
|
196
|
+
try {
|
|
197
|
+
chmodSync(dir, 0o700);
|
|
127
198
|
}
|
|
199
|
+
catch { /* not ours / unsupported */ }
|
|
128
200
|
}
|
|
129
201
|
function loadHiveState() {
|
|
130
202
|
try {
|
|
@@ -150,7 +222,26 @@ function loadHiveState() {
|
|
|
150
222
|
function saveHiveState(state) {
|
|
151
223
|
ensureHiveDir();
|
|
152
224
|
state.updatedAt = new Date().toISOString();
|
|
153
|
-
|
|
225
|
+
// state.json embeds hiveToken: write it owner-only (0600) and atomically
|
|
226
|
+
// (temp file in the same directory, then rename) so a reader never sees a
|
|
227
|
+
// half-written file and the token is never briefly world-readable.
|
|
228
|
+
const path = getHivePath();
|
|
229
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
230
|
+
try {
|
|
231
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
232
|
+
try {
|
|
233
|
+
chmodSync(tmp, 0o600);
|
|
234
|
+
}
|
|
235
|
+
catch { /* unsupported */ }
|
|
236
|
+
renameSync(tmp, path);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(tmp);
|
|
241
|
+
}
|
|
242
|
+
catch { /* already gone */ }
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
154
245
|
}
|
|
155
246
|
// Import agent store helpers for spawn functionality
|
|
156
247
|
import { existsSync as agentStoreExists, readFileSync as readAgentStore, writeFileSync as writeAgentStore, mkdirSync as mkdirAgentStore } from 'node:fs';
|
|
@@ -183,13 +274,25 @@ export const hiveMindTools = [
|
|
|
183
274
|
role: { type: 'string', enum: ['worker', 'specialist', 'scout'], description: 'Worker role in hive', default: 'worker' },
|
|
184
275
|
agentType: { type: 'string', description: 'Agent type for spawned workers', default: 'worker' },
|
|
185
276
|
prefix: { type: 'string', description: 'Prefix for worker IDs', default: 'hive-worker' },
|
|
277
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
278
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
186
279
|
},
|
|
187
280
|
},
|
|
188
|
-
handler: async (input) => {
|
|
281
|
+
handler: async (input, context) => {
|
|
189
282
|
const state = loadHiveState();
|
|
190
283
|
if (!state.initialized) {
|
|
191
284
|
return { success: false, error: 'Hive-mind not initialized. Run hive-mind/init first.' };
|
|
192
285
|
}
|
|
286
|
+
// Fail-closed: spawned agents are pushed straight into state.workers,
|
|
287
|
+
// which hive-mind_consensus's vote tally treats as legitimate voting
|
|
288
|
+
// roster members (`state.workers.includes(voterId)`). Without this
|
|
289
|
+
// gate, an unauthenticated caller could mint arbitrary "workers" here
|
|
290
|
+
// and then vote as them -- bypassing #3291's join/leave/vote Sybil
|
|
291
|
+
// fix entirely via this sibling tool. No token, no spawn.
|
|
292
|
+
const tokenError = authorizeHive(state, input, context);
|
|
293
|
+
if (tokenError) {
|
|
294
|
+
return { success: false, error: tokenError };
|
|
295
|
+
}
|
|
193
296
|
if (input.agentType) {
|
|
194
297
|
const v = validateIdentifier(input.agentType, 'agentType');
|
|
195
298
|
if (!v.valid)
|
|
@@ -258,9 +361,22 @@ export const hiveMindTools = [
|
|
|
258
361
|
description: 'Consensus strategy. Default: raft (anti-drift). Use byzantine for f<n/3 fault tolerance.',
|
|
259
362
|
},
|
|
260
363
|
queenId: { type: 'string', description: 'Initial queen agent ID' },
|
|
364
|
+
bootstrapSecret: { type: 'string', description: 'Operator secret (RUFLO_HIVE_BOOTSTRAP_SECRET or .claude-flow/hive-mind/bootstrap.secret). Only needed by remote/HTTP callers; local stdio/CLI callers omit it. See ADR-476.' },
|
|
261
365
|
},
|
|
262
366
|
},
|
|
263
|
-
handler: async (input) => {
|
|
367
|
+
handler: async (input, context) => {
|
|
368
|
+
// ADR-476: init is the credential-issuance point, so it is gated too
|
|
369
|
+
// (first-time bootstrap and re-init alike). hiveToken is not accepted
|
|
370
|
+
// here: only the operator secret may (re)initialize a hive remotely.
|
|
371
|
+
const bootstrapError = authorizeHive(undefined, input, context, { allowToken: false });
|
|
372
|
+
if (bootstrapError) {
|
|
373
|
+
return { success: false, error: bootstrapError };
|
|
374
|
+
}
|
|
375
|
+
// Local first init materialises the 0600 operator secret so a later
|
|
376
|
+
// remote/HTTP deployment has something to be told out of band. The
|
|
377
|
+
// value is never returned.
|
|
378
|
+
if (classifyCaller(context) === 'local')
|
|
379
|
+
ensureBootstrapSecret();
|
|
264
380
|
if (input.queenId) {
|
|
265
381
|
const v = validateIdentifier(input.queenId, 'queenId');
|
|
266
382
|
if (!v.valid)
|
|
@@ -295,7 +411,11 @@ export const hiveMindTools = [
|
|
|
295
411
|
consensus: state.consensusStrategy,
|
|
296
412
|
queenId,
|
|
297
413
|
status: 'initialized',
|
|
298
|
-
|
|
414
|
+
// Deliberately NOT echoing state.hiveToken here (even though this
|
|
415
|
+
// call is itself now bootstrap-secret-gated): the CLI never reads
|
|
416
|
+
// it off this response (it uses getHiveTokenForCli()'s direct
|
|
417
|
+
// same-machine state.json read instead), so there's no reason to
|
|
418
|
+
// widen the token's exposure onto an MCP tool response at all.
|
|
299
419
|
config: {
|
|
300
420
|
topology: state.topology,
|
|
301
421
|
consensus: state.consensusStrategy,
|
|
@@ -415,11 +535,12 @@ export const hiveMindTools = [
|
|
|
415
535
|
properties: {
|
|
416
536
|
agentId: { type: 'string', description: 'Agent ID to join' },
|
|
417
537
|
role: { type: 'string', enum: ['worker', 'specialist', 'scout'], description: 'Agent role in hive' },
|
|
418
|
-
hiveToken: { type: 'string', description: '
|
|
538
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
539
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
419
540
|
},
|
|
420
|
-
required: ['agentId'
|
|
541
|
+
required: ['agentId'],
|
|
421
542
|
},
|
|
422
|
-
handler: async (input) => {
|
|
543
|
+
handler: async (input, context) => {
|
|
423
544
|
const state = loadHiveState();
|
|
424
545
|
const agentId = input.agentId;
|
|
425
546
|
{
|
|
@@ -433,7 +554,7 @@ export const hiveMindTools = [
|
|
|
433
554
|
// Fail-closed: an unrecognized/missing token makes no membership
|
|
434
555
|
// change at all -- state.workers is untouched, not just left
|
|
435
556
|
// unsaved (the write below never happens on this path).
|
|
436
|
-
const tokenError =
|
|
557
|
+
const tokenError = authorizeHive(state, input, context);
|
|
437
558
|
if (tokenError) {
|
|
438
559
|
return { success: false, agentId, error: tokenError };
|
|
439
560
|
}
|
|
@@ -458,11 +579,12 @@ export const hiveMindTools = [
|
|
|
458
579
|
type: 'object',
|
|
459
580
|
properties: {
|
|
460
581
|
agentId: { type: 'string', description: 'Agent ID to remove' },
|
|
461
|
-
hiveToken: { type: 'string', description: '
|
|
582
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
583
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
462
584
|
},
|
|
463
|
-
required: ['agentId'
|
|
585
|
+
required: ['agentId'],
|
|
464
586
|
},
|
|
465
|
-
handler: async (input) => {
|
|
587
|
+
handler: async (input, context) => {
|
|
466
588
|
const state = loadHiveState();
|
|
467
589
|
const agentId = input.agentId;
|
|
468
590
|
{
|
|
@@ -472,7 +594,7 @@ export const hiveMindTools = [
|
|
|
472
594
|
}
|
|
473
595
|
// Fail-closed: a denied caller makes no membership change -- the
|
|
474
596
|
// splice/save below is unreachable on this path.
|
|
475
|
-
const tokenError =
|
|
597
|
+
const tokenError = authorizeHive(state, input, context);
|
|
476
598
|
if (tokenError) {
|
|
477
599
|
return { success: false, agentId, error: tokenError };
|
|
478
600
|
}
|
|
@@ -503,7 +625,8 @@ export const hiveMindTools = [
|
|
|
503
625
|
value: { description: 'Proposal value (for propose)' },
|
|
504
626
|
vote: { type: 'boolean', description: 'Vote (true=for, false=against)' },
|
|
505
627
|
voterId: { type: 'string', description: 'Voter agent ID' },
|
|
506
|
-
hiveToken: { type: 'string', description: '
|
|
628
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
629
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
507
630
|
strategy: { type: 'string', enum: ['bft', 'raft', 'quorum'], description: 'Consensus strategy (default: raft)' },
|
|
508
631
|
quorumPreset: { type: 'string', enum: ['unanimous', 'majority', 'supermajority'], description: 'Quorum threshold preset (for quorum strategy, default: majority)' },
|
|
509
632
|
term: { type: 'number', description: 'Term number (for raft strategy)' },
|
|
@@ -511,7 +634,7 @@ export const hiveMindTools = [
|
|
|
511
634
|
},
|
|
512
635
|
required: ['action'],
|
|
513
636
|
},
|
|
514
|
-
handler: async (input) => {
|
|
637
|
+
handler: async (input, context) => {
|
|
515
638
|
if (input.proposalId) {
|
|
516
639
|
const v = validateIdentifier(input.proposalId, 'proposalId');
|
|
517
640
|
if (!v.valid)
|
|
@@ -532,6 +655,15 @@ export const hiveMindTools = [
|
|
|
532
655
|
const strategy = input.strategy || 'raft';
|
|
533
656
|
const totalNodes = state.workers.length || 1;
|
|
534
657
|
if (action === 'propose') {
|
|
658
|
+
// Fail-closed: an unauthenticated caller could otherwise inject
|
|
659
|
+
// arbitrary consensus proposals (type/value of their choosing) for
|
|
660
|
+
// legitimate workers to vote on, or exhaust raft's one-pending-
|
|
661
|
+
// proposal-per-term slot as a denial-of-service. No token, no
|
|
662
|
+
// proposal recorded.
|
|
663
|
+
const proposeTokenError = authorizeHive(state, input, context);
|
|
664
|
+
if (proposeTokenError) {
|
|
665
|
+
return { action, error: proposeTokenError };
|
|
666
|
+
}
|
|
535
667
|
const proposalId = `proposal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
536
668
|
const quorumPreset = input.quorumPreset || 'majority';
|
|
537
669
|
const term = input.term || (state.queen?.term ?? 1);
|
|
@@ -590,7 +722,7 @@ export const hiveMindTools = [
|
|
|
590
722
|
// Fail-closed: a denied caller records no vote at all -- the
|
|
591
723
|
// votes[voterId] write below is unreachable on this path, and
|
|
592
724
|
// nothing about the proposal (vote tallies, status) changes.
|
|
593
|
-
const tokenError =
|
|
725
|
+
const tokenError = authorizeHive(state, input, context);
|
|
594
726
|
if (tokenError) {
|
|
595
727
|
return { action, error: tokenError, proposalId: proposal.proposalId };
|
|
596
728
|
}
|
|
@@ -809,14 +941,23 @@ export const hiveMindTools = [
|
|
|
809
941
|
message: { type: 'string', description: 'Message to broadcast' },
|
|
810
942
|
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'], description: 'Message priority' },
|
|
811
943
|
fromId: { type: 'string', description: 'Sender agent ID' },
|
|
944
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
945
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
812
946
|
},
|
|
813
947
|
required: ['message'],
|
|
814
948
|
},
|
|
815
|
-
handler: async (input) => {
|
|
949
|
+
handler: async (input, context) => {
|
|
816
950
|
const state = loadHiveState();
|
|
817
951
|
if (!state.initialized) {
|
|
818
952
|
return { success: false, error: 'Hive-mind not initialized' };
|
|
819
953
|
}
|
|
954
|
+
// Fail-closed: no token, no message stored -- otherwise any caller
|
|
955
|
+
// could inject spoofed broadcasts (arbitrary fromId) into shared
|
|
956
|
+
// memory that every real worker reads.
|
|
957
|
+
const tokenError = authorizeHive(state, input, context);
|
|
958
|
+
if (tokenError) {
|
|
959
|
+
return { success: false, error: tokenError };
|
|
960
|
+
}
|
|
820
961
|
{
|
|
821
962
|
const v = validateText(input.message, 'message');
|
|
822
963
|
if (!v.valid)
|
|
@@ -858,13 +999,22 @@ export const hiveMindTools = [
|
|
|
858
999
|
properties: {
|
|
859
1000
|
graceful: { type: 'boolean', description: 'Graceful shutdown (wait for pending tasks)', default: true },
|
|
860
1001
|
force: { type: 'boolean', description: 'Force immediate shutdown', default: false },
|
|
1002
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
1003
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
861
1004
|
},
|
|
862
1005
|
},
|
|
863
|
-
handler: async (input) => {
|
|
1006
|
+
handler: async (input, context) => {
|
|
864
1007
|
const state = loadHiveState();
|
|
865
1008
|
if (!state.initialized) {
|
|
866
1009
|
return { success: false, error: 'Hive-mind not initialized or already shut down' };
|
|
867
1010
|
}
|
|
1011
|
+
// Fail-closed: no token, no shutdown -- otherwise any caller could
|
|
1012
|
+
// terminate a running hive (wiping workers/pending consensus/shared
|
|
1013
|
+
// memory) as a denial-of-service with zero proof of membership.
|
|
1014
|
+
const tokenError = authorizeHive(state, input, context);
|
|
1015
|
+
if (tokenError) {
|
|
1016
|
+
return { success: false, error: tokenError };
|
|
1017
|
+
}
|
|
868
1018
|
const graceful = input.graceful !== false;
|
|
869
1019
|
const force = input.force === true;
|
|
870
1020
|
const workerCount = state.workers.length;
|
|
@@ -917,10 +1067,12 @@ export const hiveMindTools = [
|
|
|
917
1067
|
action: { type: 'string', enum: ['get', 'set', 'delete', 'list'], description: 'Memory action' },
|
|
918
1068
|
key: { type: 'string', description: 'Memory key' },
|
|
919
1069
|
value: { description: 'Value to store (for set)' },
|
|
1070
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
1071
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
920
1072
|
},
|
|
921
1073
|
required: ['action'],
|
|
922
1074
|
},
|
|
923
|
-
handler: async (input) => {
|
|
1075
|
+
handler: async (input, context) => {
|
|
924
1076
|
if (input.key) {
|
|
925
1077
|
const v = validateIdentifier(input.key, 'key');
|
|
926
1078
|
if (!v.valid)
|
|
@@ -942,6 +1094,14 @@ export const hiveMindTools = [
|
|
|
942
1094
|
if (action === 'set') {
|
|
943
1095
|
if (!key)
|
|
944
1096
|
return { action, error: 'Key required' };
|
|
1097
|
+
// Fail-closed: no token, no write -- otherwise any caller could
|
|
1098
|
+
// tamper with or inject entries into shared memory every worker
|
|
1099
|
+
// and the queen read, the same attack class hive-mind_broadcast
|
|
1100
|
+
// was gated for.
|
|
1101
|
+
const tokenError = authorizeHive(state, input, context);
|
|
1102
|
+
if (tokenError) {
|
|
1103
|
+
return { action, key, error: tokenError };
|
|
1104
|
+
}
|
|
945
1105
|
state.sharedMemory[key] = input.value;
|
|
946
1106
|
saveHiveState(state);
|
|
947
1107
|
// Also store in AgentDB for searchable hive memory
|
|
@@ -964,6 +1124,11 @@ export const hiveMindTools = [
|
|
|
964
1124
|
if (action === 'delete') {
|
|
965
1125
|
if (!key)
|
|
966
1126
|
return { action, error: 'Key required' };
|
|
1127
|
+
// Fail-closed, same reasoning as 'set' above.
|
|
1128
|
+
const tokenError = authorizeHive(state, input, context);
|
|
1129
|
+
if (tokenError) {
|
|
1130
|
+
return { action, key, error: tokenError };
|
|
1131
|
+
}
|
|
967
1132
|
const existed = key in state.sharedMemory;
|
|
968
1133
|
delete state.sharedMemory[key];
|
|
969
1134
|
saveHiveState(state);
|
|
@@ -996,13 +1161,22 @@ export const hiveMindTools = [
|
|
|
996
1161
|
type: 'object',
|
|
997
1162
|
properties: {
|
|
998
1163
|
qualityThreshold: { type: 'number', description: 'Quality threshold for pattern retention (advisory — not enforced yet)' },
|
|
1164
|
+
hiveToken: { type: 'string', description: 'Remote/HTTP callers only: hive capability token (read from the server, never returned by tools). Local stdio/CLI callers omit it. See ADR-476.' },
|
|
1165
|
+
bootstrapSecret: { type: 'string', description: 'Remote/HTTP callers only: operator secret (alternative to hiveToken). See ADR-476.' },
|
|
999
1166
|
},
|
|
1000
1167
|
},
|
|
1001
|
-
handler: async () => {
|
|
1168
|
+
handler: async (input, context) => {
|
|
1002
1169
|
const t0 = Date.now();
|
|
1003
1170
|
const state = loadHiveState();
|
|
1004
1171
|
if (!state.initialized)
|
|
1005
1172
|
return { optimized: false, error: 'Hive-mind not initialized', before: { patterns: 0, memory: '0' }, after: { patterns: 0, memory: '0' }, removed: 0, consolidated: 0, timeMs: 0 };
|
|
1173
|
+
// Fail-closed: no token, no prune -- this mutates state.sharedMemory
|
|
1174
|
+
// via the same saveHiveState() path set/delete/broadcast were gated
|
|
1175
|
+
// for (found in round 2 review, #3339).
|
|
1176
|
+
const tokenError = authorizeHive(state, input, context);
|
|
1177
|
+
if (tokenError) {
|
|
1178
|
+
return { optimized: false, error: tokenError, before: { patterns: 0, memory: '0' }, after: { patterns: 0, memory: '0' }, removed: 0, consolidated: 0, timeMs: 0 };
|
|
1179
|
+
}
|
|
1006
1180
|
const beforeKeys = Object.keys(state.sharedMemory);
|
|
1007
1181
|
const before = beforeKeys.length;
|
|
1008
1182
|
for (const k of beforeKeys) {
|