@claude-flow/cli 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.
Files changed (82) hide show
  1. package/.claude/helpers/helpers.manifest.json +3 -3
  2. package/.claude/helpers/hook-handler.cjs +65 -3
  3. package/bin/cli.js +1 -1
  4. package/bin/mcp-server.js +1 -1
  5. package/catalog-manifest.json +2 -2
  6. package/dist/src/blocking-stdio.d.ts +11 -0
  7. package/dist/src/blocking-stdio.js +23 -0
  8. package/dist/src/commands/autopilot.js +2 -2
  9. package/dist/src/commands/daemon.js +21 -1
  10. package/dist/src/commands/hive-mind.js +7 -0
  11. package/dist/src/commands/mcp.js +35 -1
  12. package/dist/src/commands/policy.js +10 -2
  13. package/dist/src/commands/session.js +11 -37
  14. package/dist/src/index.js +3 -0
  15. package/dist/src/init/executor.js +1 -0
  16. package/dist/src/init/helpers-generator.js +65 -3
  17. package/dist/src/mcp-server.d.ts +33 -0
  18. package/dist/src/mcp-server.js +96 -4
  19. package/dist/src/mcp-tools/agentdb-tools.js +13 -1
  20. package/dist/src/mcp-tools/hive-mind-tools.d.ts +2 -4
  21. package/dist/src/mcp-tools/hive-mind-tools.js +214 -40
  22. package/dist/src/mcp-tools/hooks-tools.js +1 -1
  23. package/dist/src/mcp-tools/session-tools.js +4 -1
  24. package/dist/src/memory/graph-edge-writer.js +3 -2
  25. package/dist/src/memory/lucene-bm25.js +11 -19
  26. package/dist/src/memory/memory-bridge.d.ts +1 -0
  27. package/dist/src/memory/memory-bridge.js +14 -1
  28. package/dist/src/memory/memory-initializer.js +5 -4
  29. package/dist/src/memory/shared-sqlite.d.ts +7 -0
  30. package/dist/src/memory/shared-sqlite.js +45 -0
  31. package/dist/src/mods/probe.d.ts +7 -0
  32. package/dist/src/mods/probe.js +29 -1
  33. package/dist/src/parser.d.ts +3 -0
  34. package/dist/src/parser.js +64 -13
  35. package/dist/src/production/monitoring.d.ts +2 -0
  36. package/dist/src/production/monitoring.js +8 -3
  37. package/dist/src/production/rate-limiter.js +8 -5
  38. package/dist/src/production/retry.js +3 -1
  39. package/dist/src/services/bounded-worker-pool.js +3 -0
  40. package/dist/src/services/config-file-manager.d.ts +3 -0
  41. package/dist/src/services/config-file-manager.js +24 -7
  42. package/dist/src/services/git-workspace-identity.js +20 -3
  43. package/dist/src/services/policy-ledger-anchor.d.ts +42 -0
  44. package/dist/src/services/policy-ledger-anchor.js +163 -0
  45. package/dist/src/services/policy-runtime.d.ts +11 -5
  46. package/dist/src/services/policy-runtime.js +70 -15
  47. package/dist/src/services/worker-daemon.d.ts +1 -0
  48. package/dist/src/services/worker-daemon.js +12 -0
  49. package/dist/src/services/worker-queue.d.ts +1 -0
  50. package/dist/src/services/worker-queue.js +20 -3
  51. package/node_modules/@claude-flow/mcp/dist/connection-pool.d.ts.map +1 -1
  52. package/node_modules/@claude-flow/mcp/dist/connection-pool.js +5 -2
  53. package/node_modules/@claude-flow/mcp/dist/connection-pool.js.map +1 -1
  54. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts +7 -1
  55. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
  56. package/node_modules/@claude-flow/mcp/dist/transport/http.js +60 -47
  57. package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
  58. package/node_modules/@claude-flow/mcp/package.json +1 -1
  59. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
  60. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
  61. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  62. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts +3 -0
  63. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts.map +1 -1
  64. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js +4 -1
  65. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js.map +1 -1
  66. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
  67. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +1 -0
  68. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
  69. package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
  70. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +11 -1
  71. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  72. package/node_modules/@claude-flow/security/dist/policy/engine.js +19 -3
  73. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  74. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +4 -2
  75. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  76. package/node_modules/@claude-flow/security/package.json +1 -1
  77. package/package.json +7 -7
  78. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +1 -1
  79. package/plugins/ruflo-metaharness/scripts/_invoke.mjs +80 -7
  80. package/plugins/ruflo-metaharness/scripts/smoke.sh +4 -4
  81. package/plugins/ruflo-metaharness/scripts/test-cli-resolution-3558.mjs +141 -0
  82. package/plugins/ruflo-metaharness/scripts/test-no-tool-time-npx.mjs +5 -0
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "manifest": {
3
- "version": "3.54.0",
3
+ "version": "3.55.0",
4
4
  "files": {
5
5
  "auto-memory-hook.mjs": "c8d218526a3c34034c2a69de23f507f0eb0f70d59c16426a8edc849e6ac22529",
6
- "hook-handler.cjs": "c3f9e7d2f5b33fd396d43de24fc77cb2b33eaf962114dcb9ee92a8075746b0fe",
6
+ "hook-handler.cjs": "34d2aacfb59a73ff94b1467bb57ab9d9c4af89c0e470aadf3bd0d74a503a658d",
7
7
  "intelligence.cjs": "ac5033a77bcea9796856629d5d319bde9ab6b7edf0dcd0d4af187ce30af90836",
8
8
  "statusline.cjs": "4a48353b4f1566fa4379b00fd0321b6676a22b6cc91fbbd8380ac5183d619468",
9
9
  "router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
10
10
  }
11
11
  },
12
- "signature": "670XlbndW0uatGNpiwtcNsZqZBQdr5vao9SQbLOBW7bXpMc198/7Qr59o2/AlT3vs/nQkQe+vUcngqY2IQZwBQ==",
12
+ "signature": "FT6oLMAU2RXu8iAdlOFTNGcjYZ52pkhrGFgn3/oKJdtVab0etCzO24Nr2ut1Hr9kn3tRAqb6D8MZwKfcoSqlCQ==",
13
13
  "algorithm": "ed25519"
14
14
  }
@@ -486,6 +486,7 @@ const handlers = {
486
486
  // Join literal quote fragments and normalize root paths without shell evaluation.
487
487
  function hasRootDelete(command, depth = 0) {
488
488
  let word = '', quote = '', started = false, redirect = false
489
+ let ansiNul = false // a NUL inside $'...' ends that string, as in bash
489
490
  let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
490
491
  const isRoot = (operand) => {
491
492
  if (!operand.startsWith('/')) return false
@@ -497,22 +498,55 @@ const handlers = {
497
498
  }
498
499
  return parts.length === 0 || /[*?\[]/.test(parts[0])
499
500
  }
501
+ // One backslash escape inside $'...', from the character after the backslash.
502
+ // Returns the text it stands for and the index of its last character. An unknown escape keeps its backslash.
503
+ // The command arrives lowercased, so an uppercase-U escape reads as a lowercase one: eight digits starting 0000 are one code point.
504
+ const ansiEscape = (s, at) => {
505
+ const c = s[at]
506
+ const simple = { a: '\x07', b: '\b', e: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
507
+ if (simple[c] !== undefined) return [simple[c], at]
508
+ const digits = (from, max, pattern) => pattern.exec(s.slice(from, from + max))?.[0]
509
+ const octal = digits(at, 3, /^[0-7]+/)
510
+ if (octal) return [String.fromCharCode(parseInt(octal, 8) & 255), at + octal.length - 1]
511
+ const hex = c === 'x' ? digits(at + 1, 2, /^[0-9a-f]+/) : undefined
512
+ if (hex) return [String.fromCharCode(parseInt(hex, 16)), at + hex.length]
513
+ const wide = c === 'u' ? (digits(at + 1, 8, /^0000[0-9a-f]{4}$/) ?? digits(at + 1, 4, /^[0-9a-f]+/)) : undefined
514
+ if (wide) return [String.fromCodePoint(parseInt(wide, 16)), at + wide.length]
515
+ if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
516
+ return ['\\' + c, at]
517
+ }
500
518
  const finishWord = () => {
501
519
  if (!started) return false
502
520
  // Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
503
521
  // Bound rescanning to four levels; beyond that retain its conservative check.
504
- if (word.includes('rm') && /[\s;&|()]/.test(word)) {
522
+ if (/[\s;&|()\x60]/.test(word)) {
505
523
  if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
506
524
  }
507
525
  if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
508
526
  else if (!optionsEnded && word === '--') optionsEnded = true
509
527
  else if (!optionsEnded && word.startsWith('-')) {
510
- recursive = recursive || word === '--recursive' || /^-[a-z]*r[a-z]*$/.test(word)
511
- force = force || word === '--force' || /^-[a-z]*f[a-z]*$/.test(word)
528
+ // GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
529
+ recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
530
+ force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
512
531
  } else root = root || isRoot(word)
513
532
  word = ''; started = false
514
533
  return inRm && recursive && force && root
515
534
  }
535
+ // A command substitution's body, from just after its opening backtick. Bash removes a
536
+ // backslash only before $, a backtick or a backslash (and " inside double quotes) and
537
+ // drops backslash-newline; any other backslash stays for the nested scan.
538
+ const substitution = (from, inDouble) => {
539
+ let body = '', j = from
540
+ for (; j < command.length && command[j] !== '\x60'; j++) {
541
+ const next = command[j + 1]
542
+ if (command[j] === '\\' && j + 1 < command.length) {
543
+ if (next === '\n') { j++; continue }
544
+ if (next === '$' || next === '\x60' || next === '\\' || (inDouble && next === '"')) { body += next; j++; continue }
545
+ }
546
+ body += command[j]
547
+ }
548
+ return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
549
+ }
516
550
  const finishCommand = () => {
517
551
  const denied = inRm && recursive && force && root
518
552
  inRm = optionsEnded = recursive = force = root = false
@@ -523,7 +557,25 @@ const handlers = {
523
557
  const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
524
558
  redirect = false
525
559
  if (quote) {
560
+ if (quote === "$'") {
561
+ if (char === "'") { quote = ''; ansiNul = false }
562
+ else if (char === '\\' && i + 1 < command.length) {
563
+ const [text, last] = ansiEscape(command, i + 1)
564
+ i = last
565
+ if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
566
+ } else if (!ansiNul) word += char
567
+ continue
568
+ }
526
569
  if (char === quote) quote = ''
570
+ else if (quote === '"' && char === '\x60' && command.indexOf('\x60', i + 1) > i) {
571
+ const [end, denied] = substitution(i + 1, true)
572
+ if (denied) return true
573
+ // The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
574
+ // A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
575
+ // from reading it as the start of a comment.
576
+ if (command[end + 1] === '#') word += '\u0001'
577
+ i = end
578
+ }
527
579
  else if (quote === '"' && char === '\\' && i + 1 < command.length &&
528
580
  (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
529
581
  command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
@@ -535,6 +587,16 @@ const handlers = {
535
587
  if (char === '\\' && i + 1 < command.length) {
536
588
  const next = command[++i]
537
589
  if (next !== '\n') { word += next; started = true }
590
+ } else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
591
+ // Command substitution: scan the body as its own command; the substitution
592
+ // stays inside the enclosing word, so the rm being parsed keeps its state,
593
+ // and the word has started (a # right after it is not a comment).
594
+ const [end, denied] = substitution(i + 1, false)
595
+ if (denied) return true
596
+ i = end; started = true
597
+ } else if (char === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) {
598
+ // ANSI-C ($'...': escapes decoded) and locale ($"...") quoting: the $ is not part of the word.
599
+ quote = command[i + 1] === "'" ? "$'" : '"'; started = true; i++
538
600
  } else if (char === '"' || char === "'") {
539
601
  quote = char; started = true
540
602
  } else if (char === '#' && !started) {
package/bin/cli.js CHANGED
@@ -301,7 +301,7 @@ if (isMCPMode) {
301
301
  }
302
302
 
303
303
  try {
304
- const result = await callMCPTool(toolName, toolParams, { sessionId });
304
+ const result = await callMCPTool(toolName, toolParams, { sessionId, transport: 'stdio' });
305
305
  // Failed task outcomes are data; an explicit handler error is a tool failure.
306
306
  const isError = result !== null && typeof result === 'object'
307
307
  && typeof result.error === 'string' && result.error.trim().length > 0;
package/bin/mcp-server.js CHANGED
@@ -190,7 +190,7 @@ async function handleMessage(message) {
190
190
  }
191
191
 
192
192
  try {
193
- const result = await callMCPTool(toolName, toolParams, { sessionId });
193
+ const result = await callMCPTool(toolName, toolParams, { sessionId, transport: 'stdio' });
194
194
  return {
195
195
  jsonrpc: '2.0',
196
196
  id: message.id,
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 7,
4
- "generatedAt": "2026-10-07T03:21:20.523Z",
5
- "gitSha": "70a9d5ec",
4
+ "generatedAt": "2026-10-07T17:32:59.615Z",
5
+ "gitSha": "2ab3f99f",
6
6
  "catalog": {
7
7
  "agents": 176,
8
8
  "tools": 423,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * #3851: stdout/stderr writes to a pipe are asynchronous on POSIX, so the
3
+ * `process.exit()` calls that follow a command (bin wrappers, CLI.run error
4
+ * paths, commands that exit themselves) discard whatever the consumer has not
5
+ * read yet, i.e. everything past the pipe buffer (64 KiB). Switching the pipe
6
+ * handles to blocking mode makes every write complete before it returns, so
7
+ * every exit path is safe. Same technique as the MCP stdio server.
8
+ * Files and TTYs are already synchronous and are left alone.
9
+ */
10
+ export declare function ensureBlockingStdio(): void;
11
+ //# sourceMappingURL=blocking-stdio.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * #3851: stdout/stderr writes to a pipe are asynchronous on POSIX, so the
3
+ * `process.exit()` calls that follow a command (bin wrappers, CLI.run error
4
+ * paths, commands that exit themselves) discard whatever the consumer has not
5
+ * read yet, i.e. everything past the pipe buffer (64 KiB). Switching the pipe
6
+ * handles to blocking mode makes every write complete before it returns, so
7
+ * every exit path is safe. Same technique as the MCP stdio server.
8
+ * Files and TTYs are already synchronous and are left alone.
9
+ */
10
+ export function ensureBlockingStdio() {
11
+ for (const stream of [process.stdout, process.stderr]) {
12
+ const s = stream;
13
+ if (s.isTTY)
14
+ continue;
15
+ try {
16
+ s._handle?.setBlocking?.(true);
17
+ }
18
+ catch {
19
+ // best effort: never fail the CLI because stdio could not be switched
20
+ }
21
+ }
22
+ }
23
+ //# sourceMappingURL=blocking-stdio.js.map
@@ -181,8 +181,8 @@ const configCommand = {
181
181
  name: 'config',
182
182
  description: 'Configure max iterations, timeout, and task sources',
183
183
  options: [
184
- { name: 'max-iterations', type: 'string', description: 'Max re-engagement iterations (1-1000)' },
185
- { name: 'timeout', type: 'string', description: 'Timeout in minutes (1-1440)' },
184
+ { name: 'max-iterations', type: 'number', description: 'Max re-engagement iterations (1-1000)' },
185
+ { name: 'timeout', type: 'number', description: 'Timeout in minutes (1-1440)' },
186
186
  { name: 'task-sources', type: 'string', description: `Comma-separated task sources: ${[...VALID_TASK_SOURCES].join(', ')}` },
187
187
  ],
188
188
  action: async (ctx) => {
@@ -10,6 +10,11 @@ import { fileURLToPath } from 'url';
10
10
  import { dirname, join, resolve } from 'path';
11
11
  import { homedir } from 'os';
12
12
  import * as fs from 'fs';
13
+ // #1968 / #3547: shared validator for `--workers <list>`, used both when
14
+ // reading the flag into DaemonConfig (below) and when forwarding it to the
15
+ // forked background child's argv (startBackgroundDaemon). One pattern, one
16
+ // place, so the two paths can't drift out of sync on what they accept.
17
+ const WORKERS_RE = /^[a-z][a-z0-9_-]*(,[a-z][a-z0-9_-]*)*$/;
13
18
  // Start daemon subcommand
14
19
  const startCommand = {
15
20
  name: 'start',
@@ -119,6 +124,22 @@ const startCommand = {
119
124
  output.printWarning(`Ignoring invalid --ttl value: ${sanitize(rawTtl)}`);
120
125
  }
121
126
  }
127
+ // #3547 (#1968 follow-up): `--workers` was forwarded to the forked
128
+ // background child's argv (below, in startBackgroundDaemon) but never
129
+ // read into DaemonConfig here, so WorkerDaemon always fell back to
130
+ // DEFAULT_WORKERS (7 enabled) regardless of what was requested — in
131
+ // both foreground mode (this same action, run directly) and background
132
+ // mode (this same action, re-run in the forked child with --foreground
133
+ // and the forwarded --workers flag).
134
+ const rawWorkers = ctx.flags.workers;
135
+ if (typeof rawWorkers === 'string' && rawWorkers.length > 0) {
136
+ if (WORKERS_RE.test(rawWorkers)) {
137
+ config.enabledWorkers = rawWorkers.split(',');
138
+ }
139
+ else if (!quiet) {
140
+ output.printWarning(`Ignoring invalid --workers value: ${sanitize(rawWorkers)}`);
141
+ }
142
+ }
122
143
  // Check if background daemon already running (skip if we ARE the daemon process)
123
144
  //
124
145
  // #2407 — without an atomic lockfile, N concurrent `daemon start` calls
@@ -518,7 +539,6 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
518
539
  // through — argv goes straight to a forked process so reject anything
519
540
  // that doesn't look like a comma-separated worker-name list or one of
520
541
  // the allowed sandbox modes.
521
- const WORKERS_RE = /^[a-z][a-z0-9_-]*(,[a-z][a-z0-9_-]*)*$/;
522
542
  if (typeof workers === 'string' && workers.length > 0 && WORKERS_RE.test(workers)) {
523
543
  forkArgs.push('--workers', workers);
524
544
  }
@@ -1016,6 +1016,13 @@ const consensusCommand = {
1016
1016
  const action = ctx.flags.action || 'list';
1017
1017
  try {
1018
1018
  const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId, hiveToken: getHiveTokenForCli() });
1019
+ if (result.error) {
1020
+ if (ctx.flags.format === 'json')
1021
+ output.printJson(result);
1022
+ else
1023
+ output.printError(`Consensus error: ${String(result.error)}`);
1024
+ return { success: false, exitCode: 1, data: result };
1025
+ }
1019
1026
  if (ctx.flags.format === 'json') {
1020
1027
  output.printJson(result);
1021
1028
  return { success: true, data: result };
@@ -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 ledger = await verifyPolicyLedger(root);
91
- const result = print(ledger);
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 = toSimpleYaml(result.data);
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
- fs.writeFileSync(absolutePath, content, 'utf-8');
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(content.length) }
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: content.length }
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 (absolutePath.endsWith('.yaml') || absolutePath.endsWith('.yml')) {
653
+ if (/\.ya?ml(?:\.gz)?$/i.test(absolutePath)) {
651
654
  try {
652
- JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
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',
package/dist/src/index.js CHANGED
@@ -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
@@ -1464,6 +1464,7 @@ data/
1464
1464
  logs/
1465
1465
  sessions/
1466
1466
  neural/
1467
+ console/
1467
1468
  *.log
1468
1469
  *.tmp
1469
1470
  `;
@@ -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 (word.includes('rm') && /[\s;&|()]/.test(word)) {
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
- recursive = recursive || word === '--recursive' || /^-[a-z]*r[a-z]*$/.test(word)
461
- force = force || word === '--force' || /^-[a-z]*f[a-z]*$/.test(word)
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) {