@claude-flow/cli 3.56.3 → 3.56.5

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 (73) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +13 -8
  2. package/.claude/helpers/helpers.manifest.json +5 -5
  3. package/.claude/helpers/hook-handler.cjs +19 -14
  4. package/.claude/helpers/learning-service.mjs +5 -2
  5. package/.claude/helpers/session.cjs +48 -12
  6. package/.claude/helpers/statusline.cjs +14 -4
  7. package/catalog-manifest.json +2 -2
  8. package/dist/src/commands/doctor.js +7 -13
  9. package/dist/src/commands/hooks.js +3 -6
  10. package/dist/src/commands/init.js +3 -10
  11. package/dist/src/commands/metaharness.js +9 -14
  12. package/dist/src/commands/plugins.js +81 -8
  13. package/dist/src/commands/security-code-patterns.d.ts +40 -0
  14. package/dist/src/commands/security-code-patterns.js +192 -0
  15. package/dist/src/commands/security.js +8 -11
  16. package/dist/src/commands/status.js +8 -10
  17. package/dist/src/init/executor.d.ts +1 -1
  18. package/dist/src/init/executor.js +25 -16
  19. package/dist/src/init/helper-commands.d.ts +18 -0
  20. package/dist/src/init/helper-commands.js +142 -0
  21. package/dist/src/init/helpers-generator.js +128 -29
  22. package/dist/src/init/settings-generator.js +36 -103
  23. package/dist/src/mcp-tools/metaharness-tools.js +7 -10
  24. package/dist/src/mcp-tools/task-tools.js +24 -3
  25. package/dist/src/mcp-tools/testgen-tools.js +8 -12
  26. package/dist/src/mcp-tools/transfer-tools.js +9 -8
  27. package/dist/src/mcp-tools/workflow-tools.js +10 -4
  28. package/dist/src/memory/graph-edge-writer.js +33 -11
  29. package/dist/src/memory/memory-initializer.js +2 -19
  30. package/dist/src/memory/shared-sqlite.d.ts +6 -0
  31. package/dist/src/memory/shared-sqlite.js +35 -7
  32. package/dist/src/missions/schemas.d.ts +125 -125
  33. package/dist/src/mods/command-registry/schema.d.ts +199 -199
  34. package/dist/src/plugins/manager.d.ts +3 -1
  35. package/dist/src/plugins/manager.js +31 -10
  36. package/dist/src/plugins/own-install.d.ts +15 -0
  37. package/dist/src/plugins/own-install.js +51 -0
  38. package/dist/src/plugins/store/discovery.d.ts +26 -2
  39. package/dist/src/plugins/store/discovery.js +108 -81
  40. package/dist/src/plugins/store/index.d.ts +1 -1
  41. package/dist/src/plugins/store/index.js +1 -1
  42. package/dist/src/plugins/store/types.d.ts +5 -0
  43. package/dist/src/plugins/trust-policy.d.ts +13 -0
  44. package/dist/src/plugins/trust-policy.js +29 -0
  45. package/dist/src/ruvector/graph-analyzer.js +18 -6
  46. package/dist/src/transfer/ipfs/client.js +3 -1
  47. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  48. package/node_modules/@claude-flow/mcp/dist/resource-registry.d.ts +1 -0
  49. package/node_modules/@claude-flow/mcp/dist/resource-registry.d.ts.map +1 -1
  50. package/node_modules/@claude-flow/mcp/dist/resource-registry.js +10 -4
  51. package/node_modules/@claude-flow/mcp/dist/resource-registry.js.map +1 -1
  52. package/node_modules/@claude-flow/mcp/dist/sampling.d.ts.map +1 -1
  53. package/node_modules/@claude-flow/mcp/dist/sampling.js +13 -6
  54. package/node_modules/@claude-flow/mcp/dist/sampling.js.map +1 -1
  55. package/node_modules/@claude-flow/mcp/dist/task-manager.d.ts.map +1 -1
  56. package/node_modules/@claude-flow/mcp/dist/task-manager.js +10 -1
  57. package/node_modules/@claude-flow/mcp/dist/task-manager.js.map +1 -1
  58. package/node_modules/@claude-flow/mcp/dist/tool-registry.d.ts +1 -0
  59. package/node_modules/@claude-flow/mcp/dist/tool-registry.d.ts.map +1 -1
  60. package/node_modules/@claude-flow/mcp/dist/tool-registry.js +17 -11
  61. package/node_modules/@claude-flow/mcp/dist/tool-registry.js.map +1 -1
  62. package/node_modules/@claude-flow/mcp/package.json +1 -1
  63. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  64. package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
  65. package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
  66. package/node_modules/@claude-flow/security/dist/index.js +1 -1
  67. package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
  68. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.d.ts +9 -1
  69. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.d.ts.map +1 -1
  70. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.js +15 -2
  71. package/node_modules/@claude-flow/security/dist/plugins/integrity-verifier.js.map +1 -1
  72. package/node_modules/@claude-flow/security/package.json +1 -1
  73. package/package.json +5 -5
@@ -20,6 +20,7 @@ import { generateClaudeMd } from './claudemd-generator.js';
20
20
  import { recordMemoryPackagePath } from './memory-package-resolver.js';
21
21
  import { ensureCommonJsCompanions } from './helper-companions.js';
22
22
  import { scanSettingsForRisk, formatRiskFindingsAsWarnings } from './settings-risk-scanner.js';
23
+ import { helperHookCommand, helperScopeFor, helperStatusLineCommand, pinToUserHelpers } from './helper-commands.js';
23
24
  /**
24
25
  * Skills to copy based on configuration
25
26
  */
@@ -278,12 +279,15 @@ export async function executeInit(options) {
278
279
  * Preserves user customizations while adding new features like Agent Teams
279
280
  * Uses platform-specific commands for Mac, Linux, and Windows
280
281
  */
281
- export function mergeSettingsForUpgrade(existing) {
282
+ export function mergeSettingsForUpgrade(existing, targetDir) {
282
283
  // Scan the pre-existing hooks/permissions BEFORE they get spread into
283
284
  // `merged` below — this is the untrusted, disk-sourced content a
284
285
  // malicious settings.json would use to smuggle a hook payload through.
285
286
  const warnings = formatRiskFindingsAsWarnings(scanSettingsForRisk({ hooks: existing.hooks, permissions: existing.permissions }));
286
287
  const merged = { ...existing };
288
+ // User-level settings (~/.claude/settings.json) apply to every opened
289
+ // project, so their helper commands never look inside the project.
290
+ const scope = helperScopeFor(targetDir);
287
291
  const platform = detectPlatform();
288
292
  const isWindows = platform.os === 'windows';
289
293
  // Platform-specific command wrappers
@@ -309,8 +313,12 @@ export function mergeSettingsForUpgrade(existing) {
309
313
  + "try{r=c.execSync('git rev-parse --show-toplevel',{encoding:'utf8'}).trim()}"
310
314
  + 'catch(e){r=process.cwd()}';
311
315
  const autoMemoryScript = '.claude/helpers/auto-memory-hook.mjs';
312
- const autoMemoryImportCmd = `node -e "${gitRootResolver}var f=p.join(r,'${autoMemoryScript}');import(u.pathToFileURL(f).href)" import`;
313
- const autoMemorySyncCmd = `node -e "${gitRootResolver}var f=p.join(r,'${autoMemoryScript}');import(u.pathToFileURL(f).href)" sync`;
316
+ const autoMemoryImportCmd = scope === 'user'
317
+ ? helperHookCommand(autoMemoryScript, 'import', 'user')
318
+ : `node -e "${gitRootResolver}var f=p.join(r,'${autoMemoryScript}');import(u.pathToFileURL(f).href)" import`;
319
+ const autoMemorySyncCmd = scope === 'user'
320
+ ? helperHookCommand(autoMemoryScript, 'sync', 'user')
321
+ : `node -e "${gitRootResolver}var f=p.join(r,'${autoMemoryScript}');import(u.pathToFileURL(f).href)" sync`;
314
322
  // Add auto-memory import to SessionStart (if not already present)
315
323
  const sessionStartHooks = existingHooks.SessionStart;
316
324
  const hasAutoMemoryImport = sessionStartHooks?.some(group => group.hooks?.some(h => h.command?.includes('auto-memory-hook')));
@@ -364,7 +372,8 @@ export function mergeSettingsForUpgrade(existing) {
364
372
  // / jetsam / kernel panic. Preserving the user's existing command (the
365
373
  // old behavior here) means anyone who installed pre-#2337 and upgraded
366
374
  // never gets the fix. Now we detect the broken form and overwrite.
367
- const NEW_STATUSLINE_CMD = `node -e "var c=require('child_process'),p=require('path'),r;try{r=c.execSync('git rev-parse --show-toplevel',{encoding:'utf8'}).trim()}catch(e){r=process.cwd()}var s=p.join(r,'.claude/helpers/statusline.cjs');process.argv.splice(1,0,s);require(s)"`;
375
+ const NEW_STATUSLINE_CMD = scope === 'user' ? helperStatusLineCommand('user') :
376
+ `node -e "var c=require('child_process'),p=require('path'),r;try{r=c.execSync('git rev-parse --show-toplevel',{encoding:'utf8'}).trim()}catch(e){r=process.cwd()}var s=p.join(r,'.claude/helpers/statusline.cjs');process.argv.splice(1,0,s);require(s)"`;
368
377
  // Matches any invocation of `claude-flow hooks statusline` — either via npx
369
378
  // (`npx [--prefer-offline] [@]claude-flow[/cli][@<tag>] hooks statusline …`)
370
379
  // or as a bare command (`claude-flow hooks statusline …`). Both forms cold-
@@ -381,9 +390,10 @@ export function mergeSettingsForUpgrade(existing) {
381
390
  if (existingStatusLine) {
382
391
  const existingCmd = typeof existingStatusLine.command === 'string' ? existingStatusLine.command : '';
383
392
  const isBroken = BROKEN_STATUSLINE_RE.test(existingCmd) || BROKEN_NPX_LATEST_RE.test(existingCmd);
393
+ const pinned = scope === 'user' ? pinToUserHelpers(existingCmd) : null;
384
394
  merged.statusLine = {
385
395
  type: 'command',
386
- command: isBroken || !existingCmd ? NEW_STATUSLINE_CMD : existingCmd,
396
+ command: isBroken || !existingCmd ? NEW_STATUSLINE_CMD : pinned ?? existingCmd,
387
397
  // Remove invalid fields: refreshMs, enabled (not supported by Claude Code)
388
398
  };
389
399
  }
@@ -398,15 +408,8 @@ export function mergeSettingsForUpgrade(existing) {
398
408
  // migration on already-correct settings is a no-op.
399
409
  // Bounded for the same reason as BROKEN_STATUSLINE_RE above (CodeQL js/redos).
400
410
  const BROKEN_HOOK_RE = /npx\s+(?:--?\S+\s+){0,10}@?claude-flow\/cli@latest\s+hooks\s+(\S+)/;
401
- const localHookCmd = (sub) => {
402
- // POSIX form mirrors settings-generator.ts::hookCmd() exactly.
403
- // Windows users hit a separate code path (cmd /c …) — Claude Code on
404
- // Windows is rarer and the migration there is left to a follow-up.
405
- if (process.platform === 'win32') {
406
- return `cmd /c "IF EXIST \"%CLAUDE_PROJECT_DIR%\\.claude\\helpers\\hook-handler.cjs\" (node \"%CLAUDE_PROJECT_DIR%\\.claude\\helpers\\hook-handler.cjs\" ${sub}) ELSE (node \"%USERPROFILE%\\.claude\\helpers\\hook-handler.cjs\" ${sub})"`;
407
- }
408
- return `sh -c 'D="\${CLAUDE_PROJECT_DIR:-.}"; [ -f "$D/.claude/helpers/hook-handler.cjs" ] || D="\${HOME}"; exec node "$D/.claude/helpers/hook-handler.cjs" ${sub}'`;
409
- };
411
+ // Same command settings-generator.ts emits for this settings scope.
412
+ const localHookCmd = (sub) => helperHookCommand('.claude/helpers/hook-handler.cjs', sub, scope);
410
413
  const mergedHooks = merged.hooks;
411
414
  if (mergedHooks) {
412
415
  for (const eventName of Object.keys(mergedHooks)) {
@@ -419,6 +422,12 @@ export function mergeSettingsForUpgrade(existing) {
419
422
  group.hooks = group.hooks.filter((h) => {
420
423
  if (typeof h?.command !== 'string')
421
424
  return true;
425
+ // A user-level probe ruflo generated earlier is re-pinned to HOME.
426
+ const pinned = scope === 'user' ? pinToUserHelpers(h.command) : null;
427
+ if (pinned) {
428
+ h.command = pinned;
429
+ return true;
430
+ }
422
431
  const m = BROKEN_HOOK_RE.exec(h.command);
423
432
  if (m) {
424
433
  // Subcommand captured (e.g. "pre-bash", "post-edit", "route") — keep it.
@@ -655,7 +664,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
655
664
  if (fs.existsSync(settingsPath)) {
656
665
  try {
657
666
  const existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
658
- const { merged: mergedSettings, warnings: settingsRiskWarnings } = mergeSettingsForUpgrade(existingSettings);
667
+ const { merged: mergedSettings, warnings: settingsRiskWarnings } = mergeSettingsForUpgrade(existingSettings, targetDir);
659
668
  fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf-8');
660
669
  result.updated.push('.claude/settings.json');
661
670
  result.settingsUpdated = [
@@ -677,7 +686,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
677
686
  }
678
687
  else {
679
688
  // Create new settings.json with defaults
680
- const defaultSettings = generateSettings(DEFAULT_INIT_OPTIONS);
689
+ const defaultSettings = generateSettings({ ...DEFAULT_INIT_OPTIONS, targetDir });
681
690
  fs.writeFileSync(settingsPath, JSON.stringify(defaultSettings, null, 2), 'utf-8');
682
691
  result.created.push('.claude/settings.json');
683
692
  result.settingsUpdated = ['Created new settings.json with Agent Teams'];
@@ -0,0 +1,18 @@
1
+ export type HelperScope = 'project' | 'user';
2
+ /** Settings written into the user's home directory are user-level settings. */
3
+ export declare function helperScopeFor(targetDir: string | undefined): HelperScope;
4
+ /** Command that runs `node <script> <subcommand>` for a hook. */
5
+ export declare function helperHookCommand(script: string, subcommand: string, scope: HelperScope, windows?: boolean): string;
6
+ /** Command for the statusLine entry. */
7
+ export declare function helperStatusLineCommand(scope: HelperScope, windows?: boolean): string;
8
+ /**
9
+ * Rewrite a command that runs one of ruflo's helpers from the opened project
10
+ * into the pinned user-level form, keeping its subcommand. Covers every form
11
+ * ruflo has shipped: the #1943 sh/cmd probes, the earlier
12
+ * `${CLAUDE_PROJECT_DIR:-.}` / `%CLAUDE_PROJECT_DIR%` forms, the ADR-059
13
+ * `git rev-parse` node -e forms (CJS and ESM, with or without argv.splice),
14
+ * and plain relative paths. Returns null for anything else, so user-authored
15
+ * commands (other scripts, redirects, chains) are left alone.
16
+ */
17
+ export declare function pinToUserHelpers(command: string, windows?: boolean): string | null;
18
+ //# sourceMappingURL=helper-commands.d.ts.map
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Hook and statusline commands that launch the `.claude/helpers` scripts.
3
+ *
4
+ * A project-level settings.json (`<project>/.claude/settings.json`) keeps the
5
+ * #1943 probe: run the project's helper, fall back to `$HOME/.claude/helpers`.
6
+ * The project controls that settings file anyway, so preferring its helpers
7
+ * grants it nothing new.
8
+ *
9
+ * A user-level settings.json (`~/.claude/settings.json`) applies to every
10
+ * project the user opens. There the probe would let any opened project supply
11
+ * the helper that runs, so user-level commands are pinned to the user's own
12
+ * `$HOME/.claude/helpers` with no project lookup.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ const IS_WINDOWS = process.platform === 'win32';
18
+ const STATUSLINE_SCRIPT = '.claude/helpers/statusline.cjs';
19
+ function canonical(dir) {
20
+ try {
21
+ return fs.realpathSync(dir);
22
+ }
23
+ catch {
24
+ return path.resolve(dir);
25
+ }
26
+ }
27
+ /** Settings written into the user's home directory are user-level settings. */
28
+ export function helperScopeFor(targetDir) {
29
+ if (!targetDir)
30
+ return 'project';
31
+ return canonical(targetDir) === canonical(os.homedir()) ? 'user' : 'project';
32
+ }
33
+ /** Command that runs `node <script> <subcommand>` for a hook. */
34
+ export function helperHookCommand(script, subcommand, scope, windows = IS_WINDOWS) {
35
+ const winScript = script.replace(/\//g, '\\');
36
+ const arg = subcommand ? ` ${subcommand}` : '';
37
+ if (scope === 'user') {
38
+ return windows
39
+ ? `cmd /c "node "%USERPROFILE%\\${winScript}"${arg}"`
40
+ : `sh -c 'exec node "\${HOME}/${script}"${arg}'`;
41
+ }
42
+ if (windows) {
43
+ // cmd.exe equivalent of the sh probe below. `IF EXIST` checks the
44
+ // project-local path; falls back to %USERPROFILE% if missing.
45
+ return `cmd /c "IF EXIST \"%CLAUDE_PROJECT_DIR%\\${winScript}\" (node \"%CLAUDE_PROJECT_DIR%\\${winScript}\"${arg}) ELSE (node \"%USERPROFILE%\\${winScript}\"${arg})"`;
46
+ }
47
+ // POSIX sh: prefer project-local helpers, fall back to $HOME/.claude/.
48
+ // eslint-disable-next-line no-template-curly-in-string
49
+ const projVar = '${CLAUDE_PROJECT_DIR:-.}';
50
+ // eslint-disable-next-line no-template-curly-in-string
51
+ const homeVar = '${HOME}';
52
+ return `sh -c 'D="${projVar}"; [ -f "$D/${script}" ] || D="${homeVar}"; exec node "$D/${script}"${arg}'`;
53
+ }
54
+ /** Command for the statusLine entry. */
55
+ export function helperStatusLineCommand(scope, windows = IS_WINDOWS) {
56
+ if (windows) {
57
+ // The Node CLI's `-e` flag avoids all shell-quoting pitfalls (#1948,
58
+ // #1973): never `cmd /c` for statusline, it blocks stdin forwarding.
59
+ const home = "p.join(process.env.USERPROFILE||process.env.HOME||'.', '" + STATUSLINE_SCRIPT + "')";
60
+ const js = scope === 'user'
61
+ ? `const p=require('path');require(${home});`
62
+ : "const fs=require('fs'),p=require('path');" +
63
+ `const d=process.env.CLAUDE_PROJECT_DIR||'.';` +
64
+ `const f=p.join(d,'${STATUSLINE_SCRIPT}');` +
65
+ `const h=${home};` +
66
+ 'require(fs.existsSync(f)?f:h);';
67
+ return `node -e "${js}"`;
68
+ }
69
+ if (scope === 'user')
70
+ return `sh -c 'exec node "\${HOME}/${STATUSLINE_SCRIPT}"'`;
71
+ // eslint-disable-next-line no-template-curly-in-string
72
+ const projVar = '${CLAUDE_PROJECT_DIR:-.}';
73
+ // eslint-disable-next-line no-template-curly-in-string
74
+ const homeVar = '${HOME}';
75
+ return `sh -c 'D="${projVar}"; [ -f "$D/${STATUSLINE_SCRIPT}" ] || D="${homeVar}"; exec node "$D/${STATUSLINE_SCRIPT}"'`;
76
+ }
77
+ // Helpers ruflo installs under .claude/helpers that settings can launch.
78
+ const RUFLO_HELPERS = [
79
+ 'hook-handler.cjs', 'auto-memory-hook.mjs', 'statusline.cjs', 'ruflo-hook.cjs', 'router.cjs',
80
+ 'session.cjs', 'memory.cjs', 'intelligence.cjs', 'learning-service.mjs', 'metrics-db.mjs',
81
+ 'context-persistence-hook.mjs',
82
+ ];
83
+ const HELPER_REF = new RegExp(`\\.claude[\\\\/]helpers[\\\\/](${RUFLO_HELPERS.map((h) => h.replace('.', '\\.')).join('|')})`, 'g');
84
+ // How a ruflo-generated command reached the project's copy of a helper:
85
+ // CLAUDE_PROJECT_DIR (sh, cmd or node -e), `git rev-parse --show-toplevel`,
86
+ // or a path relative to the cwd.
87
+ const PROJECT_REACH = /CLAUDE_PROJECT_DIR|git rev-parse --show-toplevel/;
88
+ const RELATIVE_REF = /(?:^|[\s"'])(?:\.[\\/])?\.claude[\\/]helpers[\\/]/;
89
+ const SHELL_CHAINING = /&&|\|\||[;|<>`]/;
90
+ const ARGS = /^(?: [\w:.=-]+)*$/;
91
+ // The fixed shell scaffolding of the #1943 probes, which itself contains `;`
92
+ // and `||`; removed before checking the rest for chaining.
93
+ const PROBE_PREAMBLE = /D="\$\{CLAUDE_PROJECT_DIR:-\.\}"; \[ -f "\$D\/\.claude\/helpers\/[\w.-]+" \] \|\| D="\$\{HOME\}"; /;
94
+ const IF_EXIST_PREAMBLE = /"IF EXIST "%CLAUDE_PROJECT_DIR%\\\.claude\\helpers\\[\w.-]+" \(node "%CLAUDE_PROJECT_DIR%\\\.claude\\helpers\\[\w.-]+"(?: [\w:.=-]+)*\) ELSE \(/;
95
+ /**
96
+ * Rewrite a command that runs one of ruflo's helpers from the opened project
97
+ * into the pinned user-level form, keeping its subcommand. Covers every form
98
+ * ruflo has shipped: the #1943 sh/cmd probes, the earlier
99
+ * `${CLAUDE_PROJECT_DIR:-.}` / `%CLAUDE_PROJECT_DIR%` forms, the ADR-059
100
+ * `git rev-parse` node -e forms (CJS and ESM, with or without argv.splice),
101
+ * and plain relative paths. Returns null for anything else, so user-authored
102
+ * commands (other scripts, redirects, chains) are left alone.
103
+ */
104
+ export function pinToUserHelpers(command, windows = IS_WINDOWS) {
105
+ const refs = [...command.matchAll(HELPER_REF)];
106
+ if (refs.length === 0)
107
+ return null;
108
+ const helper = refs[refs.length - 1][1];
109
+ if (refs.some((m) => m[1] !== helper))
110
+ return null;
111
+ if (!PROJECT_REACH.test(command) && !RELATIVE_REF.test(command))
112
+ return null;
113
+ let args;
114
+ const nodeEval = /^node -e "(.*)"((?: [\w:.=-]+)*)$/.exec(command);
115
+ if (nodeEval) {
116
+ if (!/git rev-parse --show-toplevel|CLAUDE_PROJECT_DIR/.test(nodeEval[1]))
117
+ return null;
118
+ args = nodeEval[2];
119
+ }
120
+ else {
121
+ if (!/^(?:sh -c '|cmd \/c |node )/.test(command))
122
+ return null;
123
+ // Drop the shell wrapper's closing quote / paren, then split around the
124
+ // last helper reference: the head launches it, the tail is its argv.
125
+ const body = command.replace(/'$/, '').replace(/\)"$/, '');
126
+ const last = refs[refs.length - 1];
127
+ const head = body.slice(0, last.index ?? 0).replace(PROBE_PREAMBLE, '').replace(IF_EXIST_PREAMBLE, '');
128
+ let tail = body.slice((last.index ?? 0) + last[0].length);
129
+ if (tail.startsWith('"'))
130
+ tail = tail.slice(1);
131
+ if (SHELL_CHAINING.test(head) || SHELL_CHAINING.test(tail))
132
+ return null;
133
+ args = tail.trimEnd();
134
+ }
135
+ if (!ARGS.test(args))
136
+ return null;
137
+ const script = `.claude/helpers/${helper}`;
138
+ if (helper === 'statusline.cjs' && args === '')
139
+ return helperStatusLineCommand('user', windows);
140
+ return helperHookCommand(script, args.trim(), 'user', windows);
141
+ }
142
+ //# sourceMappingURL=helper-commands.js.map
@@ -127,10 +127,52 @@ export function generateSessionManager() {
127
127
 
128
128
  const fs = require('fs');
129
129
  const path = require('path');
130
+ const crypto = require('crypto');
130
131
 
131
132
  const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions');
132
133
  const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
133
134
 
135
+ // Archive names are generated by start(); anything else in current.json is
136
+ // untrusted project content and must never pick a path.
137
+ const SESSION_ID = /^session-\\d+$/;
138
+
139
+ // current.json lives in the opened project. A symlink there must never be read
140
+ // or written through, so only a regular file counts as an active session.
141
+ function hasSessionFile() {
142
+ try { return fs.lstatSync(SESSION_FILE).isFile(); } catch { return false; }
143
+ }
144
+
145
+ function archiveId(id) {
146
+ return SESSION_ID.test(String(id)) ? String(id) : \`session-\${Date.now()}\`;
147
+ }
148
+
149
+ // The session directory must resolve inside the project (no symlinked escape).
150
+ function assertSessionDirContained() {
151
+ const root = fs.realpathSync(process.cwd());
152
+ // Resolve the deepest part of the path that exists (lstat: a symlink counts),
153
+ // so a symlinked .claude-flow is refused before anything is created under it.
154
+ let dir = SESSION_DIR;
155
+ for (;;) {
156
+ try { fs.lstatSync(dir); break; } catch { /* not created yet */ }
157
+ const parent = path.dirname(dir);
158
+ if (parent === dir) break;
159
+ dir = parent;
160
+ }
161
+ const rel = path.relative(root, fs.realpathSync(dir));
162
+ if (rel === '..' || rel.startsWith(\`..\${path.sep}\`) || path.isAbsolute(rel)) {
163
+ throw new Error('Session directory resolves outside the project');
164
+ }
165
+ }
166
+
167
+ // Exclusive temp file + rename: never writes through a planted symlink, and
168
+ // rename() replaces a symlinked target instead of following it.
169
+ function atomicWrite(file, data) {
170
+ assertSessionDirContained();
171
+ const tmp = \`\${file}.tmp.\${process.pid}.\${crypto.randomBytes(6).toString('hex')}\`;
172
+ fs.writeFileSync(tmp, data, { flag: 'wx' });
173
+ fs.renameSync(tmp, file);
174
+ }
175
+
134
176
  const commands = {
135
177
  start: () => {
136
178
  const sessionId = \`session-\${Date.now()}\`;
@@ -147,29 +189,30 @@ const commands = {
147
189
  },
148
190
  };
149
191
 
192
+ assertSessionDirContained();
150
193
  fs.mkdirSync(SESSION_DIR, { recursive: true });
151
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
194
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
152
195
 
153
196
  console.log(\`Session started: \${sessionId}\`);
154
197
  return session;
155
198
  },
156
199
 
157
200
  restore: () => {
158
- if (!fs.existsSync(SESSION_FILE)) {
201
+ if (!hasSessionFile()) {
159
202
  console.log('No session to restore');
160
203
  return null;
161
204
  }
162
205
 
163
206
  const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
164
207
  session.restoredAt = new Date().toISOString();
165
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
208
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
166
209
 
167
210
  console.log(\`Session restored: \${session.id}\`);
168
211
  return session;
169
212
  },
170
213
 
171
214
  end: () => {
172
- if (!fs.existsSync(SESSION_FILE)) {
215
+ if (!hasSessionFile()) {
173
216
  console.log('No active session');
174
217
  return null;
175
218
  }
@@ -178,12 +221,12 @@ const commands = {
178
221
  session.endedAt = new Date().toISOString();
179
222
  session.duration = Date.now() - new Date(session.startedAt).getTime();
180
223
 
181
- // Archive session
182
- const archivePath = path.join(SESSION_DIR, \`\${session.id}.json\`);
183
- fs.writeFileSync(archivePath, JSON.stringify(session, null, 2));
224
+ // Archive session under a validated name; a crafted id cannot pick the path.
225
+ const id = archiveId(session.id);
226
+ atomicWrite(path.join(SESSION_DIR, \`\${id}.json\`), JSON.stringify(session, null, 2));
184
227
  fs.unlinkSync(SESSION_FILE);
185
228
 
186
- console.log(\`Session ended: \${session.id}\`);
229
+ console.log(\`Session ended: \${id}\`);
187
230
  console.log(\`Duration: \${Math.round(session.duration / 1000 / 60)} minutes\`);
188
231
  console.log(\`Metrics: \${JSON.stringify(session.metrics)}\`);
189
232
 
@@ -191,7 +234,7 @@ const commands = {
191
234
  },
192
235
 
193
236
  status: () => {
194
- if (!fs.existsSync(SESSION_FILE)) {
237
+ if (!hasSessionFile()) {
195
238
  console.log('No active session');
196
239
  return null;
197
240
  }
@@ -208,7 +251,7 @@ const commands = {
208
251
  },
209
252
 
210
253
  update: (key, value) => {
211
- if (!fs.existsSync(SESSION_FILE)) {
254
+ if (!hasSessionFile()) {
212
255
  console.log('No active session');
213
256
  return null;
214
257
  }
@@ -216,20 +259,20 @@ const commands = {
216
259
  const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
217
260
  session.context[key] = value;
218
261
  session.updatedAt = new Date().toISOString();
219
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
262
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
220
263
 
221
264
  return session;
222
265
  },
223
266
 
224
267
  metric: (name) => {
225
- if (!fs.existsSync(SESSION_FILE)) {
268
+ if (!hasSessionFile()) {
226
269
  return null;
227
270
  }
228
271
 
229
272
  const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
230
273
  if (session.metrics[name] !== undefined) {
231
274
  session.metrics[name]++;
232
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
275
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
233
276
  }
234
277
 
235
278
  return session;
@@ -606,7 +649,9 @@ export function generateHookHandler() {
606
649
  'function spawnFunnelRefresh() {',
607
650
  ' try {',
608
651
  " var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';",
609
- " var args = ['--prefer-offline', '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
652
+ // --prefix: resolve the package from this helper's install root, never
653
+ // from the cwd (the opened project); the CLI still runs in the project.
654
+ " var args = ['--prefer-offline', '--prefix', path.resolve(helpersDir, '..', '..'), '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
610
655
  ' var child = spawn(cmd, args, {',
611
656
  " detached: true, stdio: 'ignore', env: Object.assign({}, process.env),",
612
657
  ' });',
@@ -1157,9 +1202,12 @@ import { fileURLToPath } from 'url';
1157
1202
 
1158
1203
  const __filename = fileURLToPath(import.meta.url);
1159
1204
  const __dirname = dirname(__filename);
1205
+ // Executable modules resolve only from the helper's own install root; a
1206
+ // home-level helper can serve an untrusted project. PROJECT_ROOT is data only.
1207
+ const MODULE_ROOT = join(__dirname, '../..');
1160
1208
  const PROJECT_ROOT = process.env.CLAUDE_PROJECT_DIR
1161
1209
  ? resolve(process.env.CLAUDE_PROJECT_DIR)
1162
- : join(__dirname, '../..');
1210
+ : MODULE_ROOT;
1163
1211
  const DATA_DIR = join(PROJECT_ROOT, '.claude-flow', 'data');
1164
1212
  const STORE_PATH = join(DATA_DIR, 'auto-memory-store.json');
1165
1213
 
@@ -1171,7 +1219,7 @@ const dim = (msg) => console.log(\` \${DIM}\${msg}\${RESET}\`);
1171
1219
  // #2545: fail LOUD instead of a silent dim skip when @claude-flow/memory is
1172
1220
  // unresolvable — self-learning imports are a no-op and the user must be told.
1173
1221
  function warnMemoryUnavailable() {
1174
- const l1 = \`[AutoMemory] @claude-flow/memory not resolvable from \${PROJECT_ROOT} — self-learning imports are DISABLED.\`;
1222
+ const l1 = \`[AutoMemory] @claude-flow/memory not resolvable from \${MODULE_ROOT} — self-learning imports are DISABLED.\`;
1175
1223
  const l2 = ' Fix: npm i -D @claude-flow/memory (or re-run: npx ruflo@latest init, then npx ruflo@latest doctor --fix)';
1176
1224
  console.log(\`\${YELLOW}\${l1}\${RESET}\`);
1177
1225
  console.log(\`\${YELLOW}\${l2}\${RESET}\`);
@@ -1186,7 +1234,7 @@ async function loadMemoryPackage() {
1186
1234
  // path @claude-flow/memory lands in the npx cache (unreachable by walk-up), so
1187
1235
  // init records its absolute path here — the only strategy that works there.
1188
1236
  try {
1189
- const sidecar = join(PROJECT_ROOT, '.claude-flow', 'memory-package.json');
1237
+ const sidecar = join(MODULE_ROOT, '.claude-flow', 'memory-package.json');
1190
1238
  if (existsSync(sidecar)) {
1191
1239
  const rec = JSON.parse(readFileSync(sidecar, 'utf-8'));
1192
1240
  if (rec && rec.distPath && existsSync(rec.distPath)) {
@@ -1199,15 +1247,15 @@ async function loadMemoryPackage() {
1199
1247
  // when installed as a transitive dependency via npx ruflo / npx claude-flow)
1200
1248
  try {
1201
1249
  const { createRequire } = await import('module');
1202
- const require = createRequire(join(PROJECT_ROOT, 'package.json'));
1250
+ const require = createRequire(join(MODULE_ROOT, 'package.json'));
1203
1251
  return require('@claude-flow/memory');
1204
1252
  } catch { /* fall through */ }
1205
1253
 
1206
1254
  // Strategy 2: ESM import (works when @claude-flow/memory is a direct dependency)
1207
1255
  try { return await import('@claude-flow/memory'); } catch { /* fall through */ }
1208
1256
 
1209
- // Strategy 3: Walk up from PROJECT_ROOT looking for the package in any node_modules
1210
- let searchDir = PROJECT_ROOT;
1257
+ // Strategy 3: Walk up from MODULE_ROOT looking for the package in any node_modules
1258
+ let searchDir = MODULE_ROOT;
1211
1259
  const { parse } = await import('path');
1212
1260
  while (searchDir !== parse(searchDir).root) {
1213
1261
  const candidate = join(searchDir, 'node_modules', '@claude-flow', 'memory', 'dist', 'index.js');
@@ -1405,6 +1453,7 @@ export function generateCrossPlatformSessionManager() {
1405
1453
 
1406
1454
  const fs = require('fs');
1407
1455
  const path = require('path');
1456
+ const crypto = require('crypto');
1408
1457
  const os = require('os');
1409
1458
 
1410
1459
  // Platform-specific paths
@@ -1431,6 +1480,49 @@ function getDataDir() {
1431
1480
  const SESSION_DIR = getDataDir();
1432
1481
  const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
1433
1482
 
1483
+ // Archive names are generated by start(); anything else in current.json is
1484
+ // untrusted project content and must never pick a path.
1485
+ const SESSION_ID = /^session-\\d+$/;
1486
+
1487
+ // current.json lives in the opened project. A symlink there must never be read
1488
+ // or written through, so only a regular file counts as an active session.
1489
+ function hasSessionFile() {
1490
+ try { return fs.lstatSync(SESSION_FILE).isFile(); } catch { return false; }
1491
+ }
1492
+
1493
+ function archiveId(id) {
1494
+ return SESSION_ID.test(String(id)) ? String(id) : \`session-\${Date.now()}\`;
1495
+ }
1496
+
1497
+ // The project session directory must resolve inside the project (no
1498
+ // symlinked escape). The per-user fallback directory is not project-owned.
1499
+ function assertSessionDirContained() {
1500
+ if (SESSION_DIR !== path.join(process.cwd(), '.claude-flow', 'sessions')) return;
1501
+ const root = fs.realpathSync(process.cwd());
1502
+ // Resolve the deepest part of the path that exists (lstat: a symlink counts),
1503
+ // so a symlinked .claude-flow is refused before anything is created under it.
1504
+ let dir = SESSION_DIR;
1505
+ for (;;) {
1506
+ try { fs.lstatSync(dir); break; } catch { /* not created yet */ }
1507
+ const parent = path.dirname(dir);
1508
+ if (parent === dir) break;
1509
+ dir = parent;
1510
+ }
1511
+ const rel = path.relative(root, fs.realpathSync(dir));
1512
+ if (rel === '..' || rel.startsWith(\`..\${path.sep}\`) || path.isAbsolute(rel)) {
1513
+ throw new Error('Session directory resolves outside the project');
1514
+ }
1515
+ }
1516
+
1517
+ // Exclusive temp file + rename: never writes through a planted symlink, and
1518
+ // rename() replaces a symlinked target instead of following it.
1519
+ function atomicWrite(file, data) {
1520
+ assertSessionDirContained();
1521
+ const tmp = \`\${file}.tmp.\${process.pid}.\${crypto.randomBytes(6).toString('hex')}\`;
1522
+ fs.writeFileSync(tmp, data, { flag: 'wx' });
1523
+ fs.renameSync(tmp, file);
1524
+ }
1525
+
1434
1526
  // Ensure directory exists
1435
1527
  function ensureDir(dir) {
1436
1528
  if (!fs.existsSync(dir)) {
@@ -1440,6 +1532,7 @@ function ensureDir(dir) {
1440
1532
 
1441
1533
  const commands = {
1442
1534
  start: () => {
1535
+ assertSessionDirContained();
1443
1536
  ensureDir(SESSION_DIR);
1444
1537
  const sessionId = \`session-\${Date.now()}\`;
1445
1538
  const session = {
@@ -1450,25 +1543,25 @@ const commands = {
1450
1543
  context: {},
1451
1544
  metrics: { edits: 0, commands: 0, tasks: 0, errors: 0 }
1452
1545
  };
1453
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
1546
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
1454
1547
  console.log(\`Session started: \${sessionId}\`);
1455
1548
  return session;
1456
1549
  },
1457
1550
 
1458
1551
  restore: () => {
1459
- if (!fs.existsSync(SESSION_FILE)) {
1552
+ if (!hasSessionFile()) {
1460
1553
  console.log('No session to restore');
1461
1554
  return null;
1462
1555
  }
1463
1556
  const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
1464
1557
  session.restoredAt = new Date().toISOString();
1465
- fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
1558
+ atomicWrite(SESSION_FILE, JSON.stringify(session, null, 2));
1466
1559
  console.log(\`Session restored: \${session.id}\`);
1467
1560
  return session;
1468
1561
  },
1469
1562
 
1470
1563
  end: () => {
1471
- if (!fs.existsSync(SESSION_FILE)) {
1564
+ if (!hasSessionFile()) {
1472
1565
  console.log('No active session');
1473
1566
  return null;
1474
1567
  }
@@ -1476,17 +1569,18 @@ const commands = {
1476
1569
  session.endedAt = new Date().toISOString();
1477
1570
  session.duration = Date.now() - new Date(session.startedAt).getTime();
1478
1571
 
1479
- const archivePath = path.join(SESSION_DIR, \`\${session.id}.json\`);
1480
- fs.writeFileSync(archivePath, JSON.stringify(session, null, 2));
1572
+ // Archive under a validated name; a crafted id cannot pick the path.
1573
+ const id = archiveId(session.id);
1574
+ atomicWrite(path.join(SESSION_DIR, \`\${id}.json\`), JSON.stringify(session, null, 2));
1481
1575
  fs.unlinkSync(SESSION_FILE);
1482
1576
 
1483
- console.log(\`Session ended: \${session.id}\`);
1577
+ console.log(\`Session ended: \${id}\`);
1484
1578
  console.log(\`Duration: \${Math.round(session.duration / 1000 / 60)} minutes\`);
1485
1579
  return session;
1486
1580
  },
1487
1581
 
1488
1582
  status: () => {
1489
- if (!fs.existsSync(SESSION_FILE)) {
1583
+ if (!hasSessionFile()) {
1490
1584
  console.log('No active session');
1491
1585
  return null;
1492
1586
  }
@@ -1615,6 +1709,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
1615
1709
  ? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
1616
1710
  : [''];
1617
1711
  for (const dir of dirs) {
1712
+ // A relative or empty PATH entry resolves against the cwd, i.e. the
1713
+ // opened project; never resolve the CLI from there.
1714
+ if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
1618
1715
  for (const ext of extensions) {
1619
1716
  const base = path.resolve(dir || '.', command);
1620
1717
  const candidates = ext
@@ -1767,7 +1864,9 @@ function main() {
1767
1864
  // exists but cannot be resolved to an entrypoint still runs, via layer 2.
1768
1865
  if (resolveCommandPath('ruflo')) { invokeHook('ruflo', [], hookArgs, stdinData); done(); }
1769
1866
  if (resolveCommandPath('claude-flow')) { invokeHook('claude-flow', [], hookArgs, stdinData); done(); }
1770
- invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData);
1867
+ // --prefix: npx resolves ruflo from this helper's install root, never from
1868
+ // the cwd (the opened project); the CLI still runs in the project.
1869
+ invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', path.resolve(__dirname, '..', '..'), 'ruflo@latest'], hookArgs, stdinData);
1771
1870
  done();
1772
1871
  }
1773
1872