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.
Files changed (68) hide show
  1. package/.claude/.proven-config-version +1 -0
  2. package/.claude/proven-config.json +42 -0
  3. package/node_modules/@claude-flow/mcp/dist/connection-pool.d.ts.map +1 -1
  4. package/node_modules/@claude-flow/mcp/dist/connection-pool.js +5 -2
  5. package/node_modules/@claude-flow/mcp/dist/connection-pool.js.map +1 -1
  6. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts +7 -1
  7. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
  8. package/node_modules/@claude-flow/mcp/dist/transport/http.js +60 -47
  9. package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
  10. package/node_modules/@claude-flow/mcp/package.json +1 -1
  11. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
  12. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
  13. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  14. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts +3 -0
  15. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts.map +1 -1
  16. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js +4 -1
  17. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js.map +1 -1
  18. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
  19. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +1 -0
  20. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
  21. package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
  22. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +11 -1
  23. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  24. package/node_modules/@claude-flow/security/dist/policy/engine.js +19 -3
  25. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  26. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +4 -2
  27. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  28. package/node_modules/@claude-flow/security/package.json +1 -1
  29. package/package.json +5 -5
  30. package/v3/@claude-flow/cli/bin/cli.js +1 -1
  31. package/v3/@claude-flow/cli/bin/mcp-server.js +1 -1
  32. package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
  33. package/v3/@claude-flow/cli/dist/src/commands/autopilot.js +2 -2
  34. package/v3/@claude-flow/cli/dist/src/commands/daemon.js +21 -1
  35. package/v3/@claude-flow/cli/dist/src/commands/hive-mind.js +7 -0
  36. package/v3/@claude-flow/cli/dist/src/commands/mcp.js +35 -1
  37. package/v3/@claude-flow/cli/dist/src/commands/policy.js +10 -2
  38. package/v3/@claude-flow/cli/dist/src/mcp-server.d.ts +33 -0
  39. package/v3/@claude-flow/cli/dist/src/mcp-server.js +96 -4
  40. package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.d.ts +2 -4
  41. package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.js +214 -40
  42. package/v3/@claude-flow/cli/dist/src/missions/schemas.d.ts +125 -125
  43. package/v3/@claude-flow/cli/dist/src/mods/command-registry/schema.d.ts +199 -199
  44. package/v3/@claude-flow/cli/dist/src/parser.d.ts +3 -0
  45. package/v3/@claude-flow/cli/dist/src/parser.js +64 -13
  46. package/v3/@claude-flow/cli/dist/src/production/retry.js +1 -1
  47. package/v3/@claude-flow/cli/dist/src/services/bounded-worker-pool.js +3 -0
  48. package/v3/@claude-flow/cli/dist/src/services/config-file-manager.d.ts +3 -0
  49. package/v3/@claude-flow/cli/dist/src/services/config-file-manager.js +24 -7
  50. package/v3/@claude-flow/cli/dist/src/services/policy-ledger-anchor.d.ts +42 -0
  51. package/v3/@claude-flow/cli/dist/src/services/policy-ledger-anchor.js +163 -0
  52. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +11 -5
  53. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +70 -15
  54. package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +1 -0
  55. package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +12 -0
  56. package/v3/@claude-flow/cli/dist/src/services/worker-queue.js +11 -2
  57. package/v3/@claude-flow/cli/package.json +7 -7
  58. package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.d.ts +2 -0
  59. package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.js +32 -0
  60. package/v3/@claude-flow/shared/dist/events/event-store.js +6 -2
  61. package/v3/@claude-flow/shared/dist/mcp/connection-pool.js +5 -2
  62. package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.d.ts +2 -0
  63. package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.js +33 -0
  64. package/v3/@claude-flow/shared/dist/resilience/bulkhead.js +2 -1
  65. package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.d.ts +2 -0
  66. package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.js +38 -0
  67. package/v3/@claude-flow/shared/dist/resilience/retry.js +9 -2
  68. package/v3/@claude-flow/shared/package.json +1 -1
@@ -104,8 +104,72 @@ function processStartToken(pid) {
104
104
  }
105
105
  }
106
106
  /**
107
- * Default configuration
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
- * 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.
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
- * 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.
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 requireHiveToken(state, suppliedToken) {
94
- if (!state.hiveToken) {
95
- return 'Hive-mind has no capability token minted (re-run hive-mind_init)';
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
- if (typeof suppliedToken !== 'string' || !suppliedToken) {
98
- return 'hiveToken is required';
126
+ catch {
127
+ // unreadable file == no secret configured
99
128
  }
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';
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
- return null;
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
- * 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.
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
- writeFileSync(getHivePath(), JSON.stringify(state, null, 2), 'utf-8');
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
- hiveToken: state.hiveToken,
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: 'Capability token minted by hive-mind_init' },
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', 'hiveToken'],
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 = requireHiveToken(state, input.hiveToken);
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: 'Capability token minted by hive-mind_init' },
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', 'hiveToken'],
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 = requireHiveToken(state, input.hiveToken);
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: 'Capability token minted by hive-mind_init (required to vote)' },
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 = requireHiveToken(state, input.hiveToken);
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) {