@claude-flow/cli 3.56.8 → 3.56.9

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 (27) hide show
  1. package/.claude/helpers/helpers.manifest.json +3 -3
  2. package/.claude/helpers/hook-handler.cjs +109 -23
  3. package/catalog-manifest.json +2 -2
  4. package/dist/src/commands/analyze.js +40 -29
  5. package/dist/src/init/helpers-generator.js +109 -23
  6. package/dist/src/parser.js +32 -22
  7. package/dist/src/production/monitoring.d.ts +1 -0
  8. package/dist/src/production/monitoring.js +19 -6
  9. package/dist/src/production/rate-limiter.js +5 -4
  10. package/dist/src/production/retry.js +3 -0
  11. package/dist/src/services/config-file-manager.js +3 -3
  12. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +1 -0
  13. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
  14. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +3 -1
  15. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
  16. package/node_modules/@claude-flow/codex/dist/index.d.ts +3 -3
  17. package/node_modules/@claude-flow/codex/dist/index.js +1 -1
  18. package/node_modules/@claude-flow/codex/package.json +1 -1
  19. package/node_modules/@claude-flow/security/dist/policy/envelope.js +6 -6
  20. package/node_modules/@claude-flow/security/dist/policy/envelope.js.map +1 -1
  21. package/node_modules/@claude-flow/security/package.json +1 -1
  22. package/package.json +5 -5
  23. package/plugins/ruflo-console/dist/tui.mjs +159 -158
  24. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +1 -1
  25. package/plugins/ruflo-metaharness/CHANGELOG.md +3 -0
  26. package/plugins/ruflo-metaharness/hooks/screen.ts +10 -3
  27. package/plugins/ruflo-metaharness/scripts/smoke.sh +4 -4
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "manifest": {
3
- "version": "3.56.8",
3
+ "version": "3.56.9",
4
4
  "files": {
5
5
  "auto-memory-hook.mjs": "631fac9d518835e2852a406888316bbbddddb49e7ed8e406d2e971f46833df9a",
6
- "hook-handler.cjs": "0f855c667b8d2d04cb4fe91b14b351d14c3dfb30516dcd9d7ae156acbe0a689d",
6
+ "hook-handler.cjs": "bc12b3cae92d5a7c8deef699fa00d0ef07b57625c18dbeac4387f15725b8e09f",
7
7
  "intelligence.cjs": "f486be296eb2f0518f34b8f4453d9d0a988845e4aec6d18d8d47725aca6dd38b",
8
8
  "statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
9
9
  "router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
10
10
  }
11
11
  },
12
- "signature": "ld9RNoF9lnGKjlUpofQLwCZgtE9Y5QiNNkYZLGsSQhZGp51JhBnHymH9YWpFC3F1HBslxRnea6U2TVczJKK5Bg==",
12
+ "signature": "1qMXitpeXl6mUlSGGx2dT68gaou7Uf1Z6YS+eT1K/3h+B//G2GqbgEmHhdfa5mKmcxM6eml2KlsUgMMW/WknAA==",
13
13
  "algorithm": "ed25519"
14
14
  }
@@ -682,7 +682,8 @@ const handlers = {
682
682
  const dangerous = ['rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:'];
683
683
  // Join literal quote fragments and normalize root paths without shell evaluation.
684
684
  function hasRootDelete(command, depth = 0) {
685
- let word = '', quote = '', started = false, redirect = false
685
+ // mask has one letter per character of word: 'u' unquoted, 'q' quoted or escaped (only unquoted braces expand).
686
+ let word = '', mask = '', quote = '', started = false, redirect = false
686
687
  let ansiNul = false // a NUL inside $'...' ends that string, as in bash
687
688
  let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
688
689
  const isRoot = (operand) => {
@@ -712,23 +713,92 @@ const handlers = {
712
713
  if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
713
714
  return ['\\' + c, at]
714
715
  }
715
- const finishWord = () => {
716
- if (!started) return false
717
- // Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
718
- // Bound rescanning to four levels; beyond that retain its conservative check.
719
- if (/[\s;&|()\x60]/.test(word)) {
720
- if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
716
+ // Past the rescan bound (or the brace budget), a string is judged by its tokens: an rm and a possible root operand
717
+ // anywhere in it deny. Quote characters are dropped first, so r"m" is still rm; a path token with braces may expand to /.
718
+ const looseRootDelete = (text) => {
719
+ const tokens = text.replace(/["'\\]/g, '').split(/[\s;&|()\x60$]+/)
720
+ return tokens.some(t => t === 'rm' || t.endsWith('/rm')) && tokens.some(t => isRoot(t) || (t.includes('/') && /[{}]/.test(t)))
721
+ }
722
+ // Bash brace expansion of one word ({a,b} lists, nested or glued to text), and nothing else: no {1..3} sequences, and
723
+ // nothing inside parameter braces after a dollar sign. Only unquoted braces and commas count. Iterative and bounded:
724
+ // past BRACE_WORDS words or BRACE_CHARS characters of work in this call it returns undefined.
725
+ const BRACE_WORDS = 4096
726
+ let braceChars = 1_000_000
727
+ // The leftmost expandable group: an unquoted {...} with an unquoted comma at its own level, outside any dollar-brace parameter. One pass.
728
+ const firstGroup = (text, marks) => {
729
+ const stack = []
730
+ let dollars = 0
731
+ let best
732
+ for (let k = 0; k < text.length; k++) {
733
+ if (marks[k] !== 'u') continue
734
+ const c = text[k]
735
+ if (c === '{') {
736
+ const dollar = k > 0 && text[k - 1] === '$' && marks[k - 1] === 'u'
737
+ if (dollar) dollars++
738
+ stack.push({ open: k, commas: [], dollar: dollar || dollars > 0 })
739
+ } else if (c === ',' && stack.length > 0) stack[stack.length - 1].commas.push(k)
740
+ else if (c === '}' && stack.length > 0) {
741
+ const top = stack.pop()
742
+ if (top.dollar && text[top.open - 1] === '$' && marks[top.open - 1] === 'u') dollars--
743
+ if (!top.dollar && top.commas.length > 0 && (best === undefined || top.open < best[0])) best = [top.open, top.commas, k]
744
+ }
745
+ }
746
+ return best
747
+ }
748
+ const braceExpand = (text, marks) => {
749
+ const out = []
750
+ const todo = [[text, marks]]
751
+ while (todo.length > 0) {
752
+ const [t, m] = todo.pop()
753
+ if ((braceChars -= t.length) < 0) return undefined
754
+ const group = firstGroup(t, m)
755
+ if (group === undefined) {
756
+ out.push(t)
757
+ if (out.length > BRACE_WORDS) return undefined
758
+ continue
759
+ }
760
+ const [open, commas, close] = group
761
+ const cuts = [open, ...commas, close]
762
+ // Pushed in reverse, so the words come out in bash's left-to-right order.
763
+ for (let c = cuts.length - 2; c >= 0; c--) {
764
+ const from = cuts[c] + 1, to = cuts[c + 1]
765
+ todo.push([t.slice(0, open) + t.slice(from, to) + t.slice(close + 1), m.slice(0, open) + m.slice(from, to) + m.slice(close + 1)])
766
+ }
767
+ if (todo.length > BRACE_WORDS) return undefined
721
768
  }
722
- if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
723
- else if (!optionsEnded && word === '--') optionsEnded = true
724
- else if (!optionsEnded && word.startsWith('-')) {
769
+ return out
770
+ }
771
+ const takeWord = (w) => {
772
+ if (!inRm) inRm = w === 'rm' || w.endsWith('/rm')
773
+ else if (!optionsEnded && w === '--') optionsEnded = true
774
+ else if (!optionsEnded && w.startsWith('-')) {
725
775
  // GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
726
- recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
727
- force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
728
- } else root = root || isRoot(word)
729
- word = ''; started = false
776
+ recursive = recursive || (w.length > 2 && '--recursive'.startsWith(w)) || (/^-[a-z]*$/.test(w) && w.includes('r'))
777
+ force = force || (w.length > 2 && '--force'.startsWith(w)) || (/^-[a-z]*$/.test(w) && w.includes('f'))
778
+ } else root = root || isRoot(w)
730
779
  return inRm && recursive && force && root
731
780
  }
781
+ const finishWord = () => {
782
+ if (!started) return false
783
+ const text = word, marks = mask
784
+ word = ''; mask = ''; started = false
785
+ // Literal shell strings (e.g. sh -c 'rm -rf /', or sh -c '{rm,-rf,/}') also carried the old guard.
786
+ // Bound rescanning to four levels; beyond that judge the string by its tokens.
787
+ if (/[\s;&|()\x60{]/.test(text)) {
788
+ if (depth < 4 ? hasRootDelete(text, depth + 1) : looseRootDelete(text)) return true
789
+ }
790
+ // An unquoted {a,b} becomes separate words, as bash expands it: rm -rf {/*,} is rm -rf /*.
791
+ const words = text.includes('{') ? braceExpand(text, marks) : [text]
792
+ if (words === undefined) {
793
+ // Too many alternatives to check: one of them may be the root (an rm in progress fails closed), or the
794
+ // list itself may spell the rm: judge its tokens.
795
+ if (inRm) root = true
796
+ if (looseRootDelete(text.replace(/[{},]/g, ' '))) return true
797
+ return takeWord(text)
798
+ }
799
+ for (const w of words) if (w !== '' && takeWord(w)) return true
800
+ return false
801
+ }
732
802
  // A command substitution's body, from just after its opening backtick. Bash removes a
733
803
  // backslash only before $, a backtick or a backslash (and " inside double quotes) and
734
804
  // drops backslash-newline; any other backslash stays for the nested scan.
@@ -742,7 +812,7 @@ const handlers = {
742
812
  }
743
813
  body += command[j]
744
814
  }
745
- return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
815
+ return [j, depth < 4 ? hasRootDelete(body, depth + 1) : looseRootDelete(body)]
746
816
  }
747
817
  const finishCommand = () => {
748
818
  const denied = inRm && recursive && force && root
@@ -759,8 +829,8 @@ const handlers = {
759
829
  else if (char === '\\' && i + 1 < command.length) {
760
830
  const [text, last] = ansiEscape(command, i + 1)
761
831
  i = last
762
- if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
763
- } else if (!ansiNul) word += char
832
+ if (!ansiNul) { const nul = text.indexOf('\0'); const kept = nul < 0 ? text : text.slice(0, nul); word += kept; mask += 'q'.repeat(kept.length); if (nul >= 0) ansiNul = true }
833
+ } else if (!ansiNul) { word += char; mask += 'q' }
764
834
  continue
765
835
  }
766
836
  if (char === quote) quote = ''
@@ -770,20 +840,20 @@ const handlers = {
770
840
  // The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
771
841
  // A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
772
842
  // from reading it as the start of a comment.
773
- if (command[end + 1] === '#') word += '\u0001'
843
+ if (command[end + 1] === '#') { word += '\u0001'; mask += 'q' }
774
844
  i = end
775
845
  }
776
846
  else if (quote === '"' && char === '\\' && i + 1 < command.length &&
777
847
  (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
778
848
  command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
779
849
  const next = command[++i]
780
- if (next !== '\n') word += next
781
- } else word += char
850
+ if (next !== '\n') { word += next; mask += 'q' }
851
+ } else { word += char; mask += 'q' }
782
852
  continue
783
853
  }
784
854
  if (char === '\\' && i + 1 < command.length) {
785
855
  const next = command[++i]
786
- if (next !== '\n') { word += next; started = true }
856
+ if (next !== '\n') { word += next; mask += 'q'; started = true }
787
857
  } else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
788
858
  // Command substitution: scan the body as its own command; the substitution
789
859
  // stays inside the enclosing word, so the rm being parsed keeps its state,
@@ -806,13 +876,29 @@ const handlers = {
806
876
  redirect = char === '<' || char === '>'
807
877
  if (!redirectionAmpersand && (char === '\n' || ';|&()'.includes(char)) && finishCommand()) return true
808
878
  } else {
809
- word += char; started = true
879
+ word += char; mask += 'u'; started = true
810
880
  }
811
881
  }
812
882
  return finishWord() || finishCommand()
813
883
  }
884
+
885
+ // A fork bomb's definition as bash reads it, whatever the spacing or the name: f(){ f|f& }, function f { f|f& },
886
+ // function f(){ f|f& }. Defining one is the danger: the call may come anywhere later.
887
+ function hasForkBomb(command) {
888
+ const s = command.replace(/\s+/g, '')
889
+ for (let brace = s.indexOf('{'); brace >= 0; brace = s.indexOf('{', brace + 1)) {
890
+ const end = s.startsWith('()', brace - 2) ? brace - 2 : brace
891
+ let start = end
892
+ while (start > 0 && end - start < 72 && /[a-z0-9_:.-]/.test(s[start - 1])) start--
893
+ const run = s.slice(start, end)
894
+ for (const name of run.startsWith('function') && run.length > 8 ? [run, run.slice(8)] : [run]) {
895
+ if (name && s.startsWith(name + '|' + name + '&}', brace + 1)) return true
896
+ }
897
+ }
898
+ return false
899
+ }
814
900
  for (const d of dangerous) {
815
- if (d === 'rm -rf /' ? hasRootDelete(cmd) : cmd.includes(d)) {
901
+ if (d === 'rm -rf /' ? hasRootDelete(cmd) : d === ':(){:|:&};:' ? hasForkBomb(cmd) : cmd.includes(d)) {
816
902
  console.error(`[BLOCKED] Dangerous command detected: ${d}`);
817
903
  // Claude Code PreToolUse: exit 2 blocks execution; exit 1 is non-blocking.
818
904
  process.exit(2);
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 7,
4
- "generatedAt": "2026-10-12T17:29:00.623Z",
5
- "gitSha": "4f1b3e96",
4
+ "generatedAt": "2026-10-12T21:03:06.737Z",
5
+ "gitSha": "55e2c901",
6
6
  "catalog": {
7
7
  "agents": 176,
8
8
  "tools": 423,
@@ -97,11 +97,19 @@ const diffCommand = {
97
97
  output.printInfo(`Analyzing diff: ${output.highlight(ref)}`);
98
98
  try {
99
99
  // Call MCP tool for diff analysis
100
+ // Shape returned by the analyze_diff MCP tool (ruvector/diff-classifier.ts).
101
+ // Typed from the classifier itself so the renderer cannot drift from it again.
100
102
  const result = await callMCPTool('analyze_diff', {
101
103
  ref,
102
- includeFileRisks: verbose,
104
+ // Always request file risks: the handler computes them anyway, and the
105
+ // high-risk file list below is derived from them.
106
+ includeFileRisks: true,
103
107
  includeReviewers: showReviewers || showAll,
104
108
  });
109
+ if (result.error) {
110
+ output.printError(`Diff analysis failed: ${result.message ?? 'unknown error'}`);
111
+ return { success: false, exitCode: 1 };
112
+ }
105
113
  // JSON output
106
114
  if (formatType === 'json') {
107
115
  output.printJson(result);
@@ -110,13 +118,18 @@ const diffCommand = {
110
118
  output.writeln();
111
119
  // Summary box
112
120
  const files = result.files || [];
113
- const risk = result.risk || { overall: 'unknown', score: 0, breakdown: { fileCount: 0, totalChanges: 0, highRiskFiles: [], securityConcerns: [], breakingChanges: [], testCoverage: 'unknown' } };
114
- const classification = result.classification || { category: 'unknown', confidence: 0, reasoning: '' };
121
+ const fileRisks = result.fileRisks || [];
122
+ const risk = result.risk || { overall: 'low', score: 0, breakdown: { low: 0, medium: 0, high: 0, critical: 0 } };
123
+ const classification = result.classification;
124
+ const category = classification?.primary ?? 'unknown';
125
+ const secondary = classification?.secondary?.length ? classification.secondary.join(', ') : '';
126
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
127
+ const highRiskFiles = fileRisks.filter((r) => r.risk === 'high' || r.risk === 'critical').map((r) => r.file);
115
128
  output.printBox([
116
129
  `Ref: ${result.ref || 'HEAD'}`,
117
130
  `Files: ${files.length}`,
118
131
  `Risk: ${getRiskDisplay(risk.overall)} (${risk.score}/100)`,
119
- `Type: ${classification.category}${classification.subcategory ? ` (${classification.subcategory})` : ''}`,
132
+ `Type: ${category}${secondary ? ` (${secondary})` : ''}`,
120
133
  ``,
121
134
  result.summary || 'No summary available',
122
135
  ].join('\n'), 'Diff Analysis');
@@ -128,33 +141,28 @@ const diffCommand = {
128
141
  output.printTable({
129
142
  columns: [
130
143
  { key: 'metric', header: 'Metric', width: 25 },
131
- { key: 'value', header: 'Value', width: 30 },
144
+ { key: 'value', header: 'Value', width: 42 },
132
145
  ],
133
146
  data: [
134
147
  { metric: 'Overall Risk', value: getRiskDisplay(risk.overall) },
135
148
  { metric: 'Risk Score', value: `${risk.score}/100` },
136
- { metric: 'Files Changed', value: risk.breakdown.fileCount },
137
- { metric: 'Total Lines Changed', value: risk.breakdown.totalChanges },
138
- { metric: 'Test Coverage', value: risk.breakdown.testCoverage },
149
+ { metric: 'Files Changed', value: files.length },
150
+ { metric: 'Total Lines Changed', value: totalChanges },
151
+ { metric: 'Files by Risk', value: `low ${risk.breakdown.low} / medium ${risk.breakdown.medium} / high ${risk.breakdown.high} / critical ${risk.breakdown.critical}` },
139
152
  ],
140
153
  });
141
- // Security concerns
142
- if (risk.breakdown.securityConcerns.length > 0) {
143
- output.writeln();
144
- output.writeln(output.bold(output.warning('Security Concerns')));
145
- output.printList(risk.breakdown.securityConcerns.map(c => output.warning(c)));
146
- }
147
- // Breaking changes
148
- if (risk.breakdown.breakingChanges.length > 0) {
154
+ // Risk factors reported by the classifier
155
+ const riskFactors = classification?.riskFactors ?? [];
156
+ if (riskFactors.length > 0) {
149
157
  output.writeln();
150
- output.writeln(output.bold(output.error('Potential Breaking Changes')));
151
- output.printList(risk.breakdown.breakingChanges.map(c => output.error(c)));
158
+ output.writeln(output.bold(output.warning('Risk Factors')));
159
+ output.printList(riskFactors.map(c => output.warning(c)));
152
160
  }
153
161
  // High risk files
154
- if (risk.breakdown.highRiskFiles.length > 0) {
162
+ if (highRiskFiles.length > 0) {
155
163
  output.writeln();
156
164
  output.writeln(output.bold('High Risk Files'));
157
- output.printList(risk.breakdown.highRiskFiles.map(f => output.warning(f)));
165
+ output.printList(highRiskFiles.map(f => output.warning(f)));
158
166
  }
159
167
  }
160
168
  // Classification
@@ -168,13 +176,16 @@ const diffCommand = {
168
176
  { key: 'value', header: 'Value', width: 40 },
169
177
  ],
170
178
  data: [
171
- { field: 'Category', value: classification.category },
172
- { field: 'Subcategory', value: classification.subcategory || '-' },
173
- { field: 'Confidence', value: `${(classification.confidence * 100).toFixed(0)}%` },
179
+ { field: 'Category', value: category },
180
+ { field: 'Secondary', value: secondary || '-' },
181
+ { field: 'Confidence', value: `${((classification?.confidence ?? 0) * 100).toFixed(0)}%` },
182
+ { field: 'Impact', value: classification?.impactLevel ?? '-' },
174
183
  ],
175
184
  });
176
- output.writeln();
177
- output.writeln(output.dim(`Reasoning: ${classification.reasoning}`));
185
+ if (classification?.testingStrategy?.length) {
186
+ output.writeln();
187
+ output.writeln(output.dim(`Testing strategy: ${classification.testingStrategy.join(', ')}`));
188
+ }
178
189
  }
179
190
  // Reviewers
180
191
  if (showReviewers || showAll) {
@@ -190,13 +201,13 @@ const diffCommand = {
190
201
  }
191
202
  }
192
203
  // Verbose file-level details
193
- if (verbose && result.fileRisks) {
204
+ if (verbose && fileRisks.length > 0) {
194
205
  output.writeln();
195
206
  output.writeln(output.bold('File-Level Analysis'));
196
207
  output.writeln(output.dim('-'.repeat(50)));
197
208
  output.printTable({
198
209
  columns: [
199
- { key: 'path', header: 'File', width: 40 },
210
+ { key: 'file', header: 'File', width: 40 },
200
211
  { key: 'risk', header: 'Risk', width: 12, format: (v) => getRiskDisplay(String(v)) },
201
212
  { key: 'score', header: 'Score', width: 8, align: 'right' },
202
213
  { key: 'reasons', header: 'Reasons', width: 30, format: (v) => {
@@ -204,7 +215,7 @@ const diffCommand = {
204
215
  return reasons.slice(0, 2).join('; ');
205
216
  } },
206
217
  ],
207
- data: result.fileRisks,
218
+ data: fileRisks.map((r) => ({ ...r })),
208
219
  });
209
220
  }
210
221
  // Files changed table
@@ -219,7 +230,7 @@ const diffCommand = {
219
230
  { key: 'additions', header: '+', width: 8, align: 'right', format: (v) => output.success(`+${v}`) },
220
231
  { key: 'deletions', header: '-', width: 8, align: 'right', format: (v) => output.error(`-${v}`) },
221
232
  ],
222
- data: files.slice(0, 20),
233
+ data: files.slice(0, 20).map((f) => ({ ...f })),
223
234
  });
224
235
  if (files.length > 20) {
225
236
  output.writeln(output.dim(` ... and ${files.length - 20} more files`));
@@ -478,7 +478,8 @@ module.exports = commands;
478
478
  }
479
479
  // Literal-word root guard mirrored in the mod and both shipped classic helpers.
480
480
  const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, depth = 0) {
481
- let word = '', quote = '', started = false, redirect = false
481
+ // mask has one letter per character of word: 'u' unquoted, 'q' quoted or escaped (only unquoted braces expand).
482
+ let word = '', mask = '', quote = '', started = false, redirect = false
482
483
  let ansiNul = false // a NUL inside $'...' ends that string, as in bash
483
484
  let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
484
485
  const isRoot = (operand) => {
@@ -508,23 +509,92 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
508
509
  if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
509
510
  return ['\\' + c, at]
510
511
  }
511
- const finishWord = () => {
512
- if (!started) return false
513
- // Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
514
- // Bound rescanning to four levels; beyond that retain its conservative check.
515
- if (/[\s;&|()\x60]/.test(word)) {
516
- if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
512
+ // Past the rescan bound (or the brace budget), a string is judged by its tokens: an rm and a possible root operand
513
+ // anywhere in it deny. Quote characters are dropped first, so r"m" is still rm; a path token with braces may expand to /.
514
+ const looseRootDelete = (text) => {
515
+ const tokens = text.replace(/["'\\]/g, '').split(/[\s;&|()\x60$]+/)
516
+ return tokens.some(t => t === 'rm' || t.endsWith('/rm')) && tokens.some(t => isRoot(t) || (t.includes('/') && /[{}]/.test(t)))
517
+ }
518
+ // Bash brace expansion of one word ({a,b} lists, nested or glued to text), and nothing else: no {1..3} sequences, and
519
+ // nothing inside parameter braces after a dollar sign. Only unquoted braces and commas count. Iterative and bounded:
520
+ // past BRACE_WORDS words or BRACE_CHARS characters of work in this call it returns undefined.
521
+ const BRACE_WORDS = 4096
522
+ let braceChars = 1_000_000
523
+ // The leftmost expandable group: an unquoted {...} with an unquoted comma at its own level, outside any dollar-brace parameter. One pass.
524
+ const firstGroup = (text, marks) => {
525
+ const stack = []
526
+ let dollars = 0
527
+ let best
528
+ for (let k = 0; k < text.length; k++) {
529
+ if (marks[k] !== 'u') continue
530
+ const c = text[k]
531
+ if (c === '{') {
532
+ const dollar = k > 0 && text[k - 1] === '$' && marks[k - 1] === 'u'
533
+ if (dollar) dollars++
534
+ stack.push({ open: k, commas: [], dollar: dollar || dollars > 0 })
535
+ } else if (c === ',' && stack.length > 0) stack[stack.length - 1].commas.push(k)
536
+ else if (c === '}' && stack.length > 0) {
537
+ const top = stack.pop()
538
+ if (top.dollar && text[top.open - 1] === '$' && marks[top.open - 1] === 'u') dollars--
539
+ if (!top.dollar && top.commas.length > 0 && (best === undefined || top.open < best[0])) best = [top.open, top.commas, k]
540
+ }
541
+ }
542
+ return best
543
+ }
544
+ const braceExpand = (text, marks) => {
545
+ const out = []
546
+ const todo = [[text, marks]]
547
+ while (todo.length > 0) {
548
+ const [t, m] = todo.pop()
549
+ if ((braceChars -= t.length) < 0) return undefined
550
+ const group = firstGroup(t, m)
551
+ if (group === undefined) {
552
+ out.push(t)
553
+ if (out.length > BRACE_WORDS) return undefined
554
+ continue
555
+ }
556
+ const [open, commas, close] = group
557
+ const cuts = [open, ...commas, close]
558
+ // Pushed in reverse, so the words come out in bash's left-to-right order.
559
+ for (let c = cuts.length - 2; c >= 0; c--) {
560
+ const from = cuts[c] + 1, to = cuts[c + 1]
561
+ todo.push([t.slice(0, open) + t.slice(from, to) + t.slice(close + 1), m.slice(0, open) + m.slice(from, to) + m.slice(close + 1)])
562
+ }
563
+ if (todo.length > BRACE_WORDS) return undefined
517
564
  }
518
- if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
519
- else if (!optionsEnded && word === '--') optionsEnded = true
520
- else if (!optionsEnded && word.startsWith('-')) {
565
+ return out
566
+ }
567
+ const takeWord = (w) => {
568
+ if (!inRm) inRm = w === 'rm' || w.endsWith('/rm')
569
+ else if (!optionsEnded && w === '--') optionsEnded = true
570
+ else if (!optionsEnded && w.startsWith('-')) {
521
571
  // GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
522
- recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
523
- force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
524
- } else root = root || isRoot(word)
525
- word = ''; started = false
572
+ recursive = recursive || (w.length > 2 && '--recursive'.startsWith(w)) || (/^-[a-z]*$/.test(w) && w.includes('r'))
573
+ force = force || (w.length > 2 && '--force'.startsWith(w)) || (/^-[a-z]*$/.test(w) && w.includes('f'))
574
+ } else root = root || isRoot(w)
526
575
  return inRm && recursive && force && root
527
576
  }
577
+ const finishWord = () => {
578
+ if (!started) return false
579
+ const text = word, marks = mask
580
+ word = ''; mask = ''; started = false
581
+ // Literal shell strings (e.g. sh -c 'rm -rf /', or sh -c '{rm,-rf,/}') also carried the old guard.
582
+ // Bound rescanning to four levels; beyond that judge the string by its tokens.
583
+ if (/[\s;&|()\x60{]/.test(text)) {
584
+ if (depth < 4 ? hasRootDelete(text, depth + 1) : looseRootDelete(text)) return true
585
+ }
586
+ // An unquoted {a,b} becomes separate words, as bash expands it: rm -rf {/*,} is rm -rf /*.
587
+ const words = text.includes('{') ? braceExpand(text, marks) : [text]
588
+ if (words === undefined) {
589
+ // Too many alternatives to check: one of them may be the root (an rm in progress fails closed), or the
590
+ // list itself may spell the rm: judge its tokens.
591
+ if (inRm) root = true
592
+ if (looseRootDelete(text.replace(/[{},]/g, ' '))) return true
593
+ return takeWord(text)
594
+ }
595
+ for (const w of words) if (w !== '' && takeWord(w)) return true
596
+ return false
597
+ }
528
598
  // A command substitution's body, from just after its opening backtick. Bash removes a
529
599
  // backslash only before $, a backtick or a backslash (and " inside double quotes) and
530
600
  // drops backslash-newline; any other backslash stays for the nested scan.
@@ -538,7 +608,7 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
538
608
  }
539
609
  body += command[j]
540
610
  }
541
- return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
611
+ return [j, depth < 4 ? hasRootDelete(body, depth + 1) : looseRootDelete(body)]
542
612
  }
543
613
  const finishCommand = () => {
544
614
  const denied = inRm && recursive && force && root
@@ -555,8 +625,8 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
555
625
  else if (char === '\\' && i + 1 < command.length) {
556
626
  const [text, last] = ansiEscape(command, i + 1)
557
627
  i = last
558
- if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
559
- } else if (!ansiNul) word += char
628
+ if (!ansiNul) { const nul = text.indexOf('\0'); const kept = nul < 0 ? text : text.slice(0, nul); word += kept; mask += 'q'.repeat(kept.length); if (nul >= 0) ansiNul = true }
629
+ } else if (!ansiNul) { word += char; mask += 'q' }
560
630
  continue
561
631
  }
562
632
  if (char === quote) quote = ''
@@ -566,20 +636,20 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
566
636
  // The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
567
637
  // A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
568
638
  // from reading it as the start of a comment.
569
- if (command[end + 1] === '#') word += '\u0001'
639
+ if (command[end + 1] === '#') { word += '\u0001'; mask += 'q' }
570
640
  i = end
571
641
  }
572
642
  else if (quote === '"' && char === '\\' && i + 1 < command.length &&
573
643
  (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
574
644
  command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
575
645
  const next = command[++i]
576
- if (next !== '\n') word += next
577
- } else word += char
646
+ if (next !== '\n') { word += next; mask += 'q' }
647
+ } else { word += char; mask += 'q' }
578
648
  continue
579
649
  }
580
650
  if (char === '\\' && i + 1 < command.length) {
581
651
  const next = command[++i]
582
- if (next !== '\n') { word += next; started = true }
652
+ if (next !== '\n') { word += next; mask += 'q'; started = true }
583
653
  } else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
584
654
  // Command substitution: scan the body as its own command; the substitution
585
655
  // stays inside the enclosing word, so the rm being parsed keeps its state,
@@ -602,10 +672,26 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
602
672
  redirect = char === '<' || char === '>'
603
673
  if (!redirectionAmpersand && (char === '\n' || ';|&()'.includes(char)) && finishCommand()) return true
604
674
  } else {
605
- word += char; started = true
675
+ word += char; mask += 'u'; started = true
606
676
  }
607
677
  }
608
678
  return finishWord() || finishCommand()
679
+ }
680
+
681
+ // A fork bomb's definition as bash reads it, whatever the spacing or the name: f(){ f|f& }, function f { f|f& },
682
+ // function f(){ f|f& }. Defining one is the danger: the call may come anywhere later.
683
+ function hasForkBomb(command) {
684
+ const s = command.replace(/\s+/g, '')
685
+ for (let brace = s.indexOf('{'); brace >= 0; brace = s.indexOf('{', brace + 1)) {
686
+ const end = s.startsWith('()', brace - 2) ? brace - 2 : brace
687
+ let start = end
688
+ while (start > 0 && end - start < 72 && /[a-z0-9_:.-]/.test(s[start - 1])) start--
689
+ const run = s.slice(start, end)
690
+ for (const name of run.startsWith('function') && run.length > 8 ? [run, run.slice(8)] : [run]) {
691
+ if (name && s.startsWith(name + '|' + name + '&}', brace + 1)) return true
692
+ }
693
+ }
694
+ return false
609
695
  }`;
610
696
  /**
611
697
  * Generate hook-handler.cjs (cross-platform hook dispatcher)
@@ -816,7 +902,7 @@ export function generateHookHandler() {
816
902
  " var dangerous = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:'];",
817
903
  ROOT_DELETE_CHECK_SOURCE.split('\n').map((line) => ' ' + line).join('\n'),
818
904
  ' for (var i = 0; i < dangerous.length; i++) {',
819
- " if (dangerous[i] === 'rm -rf /' ? hasRootDelete(cmd) : cmd.includes(dangerous[i])) {",
905
+ " if (dangerous[i] === 'rm -rf /' ? hasRootDelete(cmd) : dangerous[i] === ':(){:|:&};:' ? hasForkBomb(cmd) : cmd.includes(dangerous[i])) {",
820
906
  " console.error('[BLOCKED] Dangerous command detected: ' + dangerous[i]);",
821
907
  ' // Claude Code PreToolUse: exit 2 blocks execution; exit 1 is non-blocking.',
822
908
  ' process.exit(2);',