@cleocode/core 2026.10.2 → 2026.10.4

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 (228) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/cloud/http.d.ts +2 -0
  3. package/dist/cloud/http.d.ts.map +1 -1
  4. package/dist/cloud/http.js +3 -0
  5. package/dist/cloud/http.js.map +1 -1
  6. package/dist/cloud/manifest-check.d.ts.map +1 -1
  7. package/dist/cloud/manifest-check.js +13 -0
  8. package/dist/cloud/manifest-check.js.map +1 -1
  9. package/dist/cloud/nexus-enrol.d.ts +9 -0
  10. package/dist/cloud/nexus-enrol.d.ts.map +1 -1
  11. package/dist/cloud/nexus-enrol.js +45 -2
  12. package/dist/cloud/nexus-enrol.js.map +1 -1
  13. package/dist/cloud/nexus-first-run.d.ts +142 -0
  14. package/dist/cloud/nexus-first-run.d.ts.map +1 -0
  15. package/dist/cloud/nexus-first-run.js +352 -0
  16. package/dist/cloud/nexus-first-run.js.map +1 -0
  17. package/dist/cloud/nexus-project-names.d.ts +173 -0
  18. package/dist/cloud/nexus-project-names.d.ts.map +1 -0
  19. package/dist/cloud/nexus-project-names.js +314 -0
  20. package/dist/cloud/nexus-project-names.js.map +1 -0
  21. package/dist/cloud/nexus-vault-keys.d.ts +35 -3
  22. package/dist/cloud/nexus-vault-keys.d.ts.map +1 -1
  23. package/dist/cloud/nexus-vault-keys.js +212 -22
  24. package/dist/cloud/nexus-vault-keys.js.map +1 -1
  25. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  26. package/dist/cloud/nexus-vault.js +14 -2
  27. package/dist/cloud/nexus-vault.js.map +1 -1
  28. package/dist/dispatch/contracts/derive-output-contract.d.ts +1 -1
  29. package/dist/dispatch/contracts/derive-output-contract.d.ts.map +1 -1
  30. package/dist/dispatch/contracts/derive-output-contract.js +2 -1
  31. package/dist/dispatch/contracts/derive-output-contract.js.map +1 -1
  32. package/dist/dispatch/contracts/output-contracts.d.ts +2 -1
  33. package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
  34. package/dist/dispatch/contracts/output-contracts.js +1 -1
  35. package/dist/dispatch/contracts/output-contracts.js.map +1 -1
  36. package/dist/dispatch/mvi-projection.d.ts +1 -1
  37. package/dist/dispatch/mvi-projection.d.ts.map +1 -1
  38. package/dist/dispatch/mvi-projection.js +1 -1
  39. package/dist/dispatch/mvi-projection.js.map +1 -1
  40. package/dist/docs/export-document.js +24462 -24572
  41. package/dist/docs/export-document.js.map +3 -3
  42. package/dist/doctor/db-substrate.d.ts +6 -9
  43. package/dist/doctor/db-substrate.d.ts.map +1 -1
  44. package/dist/doctor/db-substrate.js +10 -14
  45. package/dist/doctor/db-substrate.js.map +1 -1
  46. package/dist/doctor/migrations.d.ts +18 -0
  47. package/dist/doctor/migrations.d.ts.map +1 -1
  48. package/dist/doctor/migrations.js +54 -2
  49. package/dist/doctor/migrations.js.map +1 -1
  50. package/dist/error-catalog.js +1 -1
  51. package/dist/error-catalog.js.map +1 -1
  52. package/dist/errors.d.ts +1 -1
  53. package/dist/errors.d.ts.map +1 -1
  54. package/dist/errors.js +1 -1
  55. package/dist/errors.js.map +1 -1
  56. package/dist/init.d.ts.map +1 -1
  57. package/dist/init.js +21 -5
  58. package/dist/init.js.map +1 -1
  59. package/dist/llm/conversation.d.ts.map +1 -1
  60. package/dist/llm/conversation.js +15 -4
  61. package/dist/llm/conversation.js.map +1 -1
  62. package/dist/llm/plugin-facade.js +277 -376
  63. package/dist/llm/plugin-facade.js.map +3 -3
  64. package/dist/llm/transports/bedrock.d.ts +1 -0
  65. package/dist/llm/transports/bedrock.d.ts.map +1 -1
  66. package/dist/llm/transports/bedrock.js +30 -12
  67. package/dist/llm/transports/bedrock.js.map +1 -1
  68. package/dist/memory/decision-contradiction.d.ts.map +1 -1
  69. package/dist/memory/decision-contradiction.js +10 -5
  70. package/dist/memory/decision-contradiction.js.map +1 -1
  71. package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
  72. package/dist/memory/dialectic-evaluator.js +4 -1
  73. package/dist/memory/dialectic-evaluator.js.map +1 -1
  74. package/dist/memory/transcript-extractor.d.ts.map +1 -1
  75. package/dist/memory/transcript-extractor.js +3 -2
  76. package/dist/memory/transcript-extractor.js.map +1 -1
  77. package/dist/metrics/token-service.d.ts +16 -0
  78. package/dist/metrics/token-service.d.ts.map +1 -1
  79. package/dist/metrics/token-service.js +18 -0
  80. package/dist/metrics/token-service.js.map +1 -1
  81. package/dist/nexus/api-contracts.d.ts.map +1 -1
  82. package/dist/nexus/api-contracts.js +27 -11
  83. package/dist/nexus/api-contracts.js.map +1 -1
  84. package/dist/nexus/api-extractors/http-extractor.d.ts +2 -2
  85. package/dist/nexus/api-extractors/http-extractor.d.ts.map +1 -1
  86. package/dist/nexus/api-extractors/http-extractor.js +3 -3
  87. package/dist/nexus/api-extractors/http-extractor.js.map +1 -1
  88. package/dist/nexus/clusters.js +1 -1
  89. package/dist/nexus/clusters.js.map +1 -1
  90. package/dist/nexus/diff.d.ts +1 -1
  91. package/dist/nexus/diff.d.ts.map +1 -1
  92. package/dist/nexus/diff.js +3 -2
  93. package/dist/nexus/diff.js.map +1 -1
  94. package/dist/nexus/flows.js +1 -1
  95. package/dist/nexus/flows.js.map +1 -1
  96. package/dist/nexus/identity.d.ts +8 -0
  97. package/dist/nexus/identity.d.ts.map +1 -1
  98. package/dist/nexus/identity.js +17 -1
  99. package/dist/nexus/identity.js.map +1 -1
  100. package/dist/nexus/index.d.ts +1 -1
  101. package/dist/nexus/index.d.ts.map +1 -1
  102. package/dist/nexus/index.js +2 -2
  103. package/dist/nexus/index.js.map +1 -1
  104. package/dist/nexus/nexus-bridge.d.ts.map +1 -1
  105. package/dist/nexus/nexus-bridge.js +5 -5
  106. package/dist/nexus/nexus-bridge.js.map +1 -1
  107. package/dist/nexus/registry.d.ts +22 -0
  108. package/dist/nexus/registry.d.ts.map +1 -1
  109. package/dist/nexus/registry.js +76 -1
  110. package/dist/nexus/registry.js.map +1 -1
  111. package/dist/paths.js +1 -1
  112. package/dist/paths.js.map +1 -1
  113. package/dist/project-scope.js +1 -1
  114. package/dist/project-scope.js.map +1 -1
  115. package/dist/project-tombstone.js +1 -1
  116. package/dist/project-tombstone.js.map +1 -1
  117. package/dist/release/open.d.ts +3 -6
  118. package/dist/release/open.d.ts.map +1 -1
  119. package/dist/release/open.js +59 -23
  120. package/dist/release/open.js.map +1 -1
  121. package/dist/release/preflight-skip.d.ts +69 -10
  122. package/dist/release/preflight-skip.d.ts.map +1 -1
  123. package/dist/release/preflight-skip.js +195 -47
  124. package/dist/release/preflight-skip.js.map +1 -1
  125. package/dist/render/colors.js +1 -19
  126. package/dist/render/colors.js.map +1 -1
  127. package/dist/render/tasks/colors.js +1 -1
  128. package/dist/render/tasks/colors.js.map +1 -1
  129. package/dist/resources/governor.d.ts +12 -0
  130. package/dist/resources/governor.d.ts.map +1 -1
  131. package/dist/resources/governor.js +5 -1
  132. package/dist/resources/governor.js.map +1 -1
  133. package/dist/resources/heavy-command-hook-delivery.d.ts +144 -0
  134. package/dist/resources/heavy-command-hook-delivery.d.ts.map +1 -0
  135. package/dist/resources/heavy-command-hook-delivery.js +204 -0
  136. package/dist/resources/heavy-command-hook-delivery.js.map +1 -0
  137. package/dist/resources/heavy-command.d.ts +69 -0
  138. package/dist/resources/heavy-command.d.ts.map +1 -1
  139. package/dist/resources/heavy-command.js +479 -7
  140. package/dist/resources/heavy-command.js.map +1 -1
  141. package/dist/resources/run-class.d.ts.map +1 -1
  142. package/dist/resources/run-class.js +1 -0
  143. package/dist/resources/run-class.js.map +1 -1
  144. package/dist/routing/build-command-groups.d.ts +1 -1
  145. package/dist/routing/build-command-groups.d.ts.map +1 -1
  146. package/dist/routing/build-command-groups.js +4 -1
  147. package/dist/routing/build-command-groups.js.map +1 -1
  148. package/dist/scaffold/ensure-skills.d.ts.map +1 -1
  149. package/dist/scaffold/ensure-skills.js +58 -2
  150. package/dist/scaffold/ensure-skills.js.map +1 -1
  151. package/dist/selfimprove/fix-gen.js +277 -376
  152. package/dist/selfimprove/fix-gen.js.map +3 -3
  153. package/dist/sessions/briefing.d.ts.map +1 -1
  154. package/dist/sessions/briefing.js +11 -0
  155. package/dist/sessions/briefing.js.map +1 -1
  156. package/dist/store/dual-scope-db.d.ts.map +1 -1
  157. package/dist/store/dual-scope-db.js +6 -1
  158. package/dist/store/dual-scope-db.js.map +1 -1
  159. package/dist/store/portable-bundle-scan.d.ts +22 -0
  160. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  161. package/dist/store/portable-bundle-scan.js +97 -25
  162. package/dist/store/portable-bundle-scan.js.map +1 -1
  163. package/dist/store/portable-bundle.d.ts +7 -0
  164. package/dist/store/portable-bundle.d.ts.map +1 -1
  165. package/dist/store/portable-bundle.js +10 -1
  166. package/dist/store/portable-bundle.js.map +1 -1
  167. package/dist/store/table-classification.d.ts +27 -0
  168. package/dist/store/table-classification.d.ts.map +1 -1
  169. package/dist/store/table-classification.js +32 -0
  170. package/dist/store/table-classification.js.map +1 -1
  171. package/dist/store/vault-manifest.d.ts +5 -1
  172. package/dist/store/vault-manifest.d.ts.map +1 -1
  173. package/dist/store/vault-manifest.js +9 -2
  174. package/dist/store/vault-manifest.js.map +1 -1
  175. package/dist/system/health.d.ts +13 -0
  176. package/dist/system/health.d.ts.map +1 -1
  177. package/dist/system/health.js +96 -1
  178. package/dist/system/health.js.map +1 -1
  179. package/dist/tasks/affected-packages.d.ts +20 -7
  180. package/dist/tasks/affected-packages.d.ts.map +1 -1
  181. package/dist/tasks/affected-packages.js +39 -13
  182. package/dist/tasks/affected-packages.js.map +1 -1
  183. package/dist/tasks/affected-template.d.ts +93 -0
  184. package/dist/tasks/affected-template.d.ts.map +1 -0
  185. package/dist/tasks/affected-template.js +198 -0
  186. package/dist/tasks/affected-template.js.map +1 -0
  187. package/dist/tasks/command-line.d.ts +4 -1
  188. package/dist/tasks/command-line.d.ts.map +1 -1
  189. package/dist/tasks/command-line.js +7 -2
  190. package/dist/tasks/command-line.js.map +1 -1
  191. package/dist/tasks/done-plan.d.ts.map +1 -1
  192. package/dist/tasks/done-plan.js +12 -2
  193. package/dist/tasks/done-plan.js.map +1 -1
  194. package/dist/tasks/duplicate-detector.d.ts.map +1 -1
  195. package/dist/tasks/duplicate-detector.js +11 -5
  196. package/dist/tasks/duplicate-detector.js.map +1 -1
  197. package/dist/tasks/evidence.d.ts.map +1 -1
  198. package/dist/tasks/evidence.js +185 -11
  199. package/dist/tasks/evidence.js.map +1 -1
  200. package/dist/tasks/heavy-tool-env.d.ts +385 -18
  201. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  202. package/dist/tasks/heavy-tool-env.js +719 -40
  203. package/dist/tasks/heavy-tool-env.js.map +1 -1
  204. package/dist/tasks/index.d.ts +2 -2
  205. package/dist/tasks/index.d.ts.map +1 -1
  206. package/dist/tasks/index.js +1 -1
  207. package/dist/tasks/index.js.map +1 -1
  208. package/dist/tasks/tool-cache-env.d.ts +20 -13
  209. package/dist/tasks/tool-cache-env.d.ts.map +1 -1
  210. package/dist/tasks/tool-cache-env.js +27 -48
  211. package/dist/tasks/tool-cache-env.js.map +1 -1
  212. package/dist/tasks/tool-cache.d.ts +8 -0
  213. package/dist/tasks/tool-cache.d.ts.map +1 -1
  214. package/dist/tasks/tool-cache.js +19 -6
  215. package/dist/tasks/tool-cache.js.map +1 -1
  216. package/dist/tasks/tool-semaphore.d.ts +66 -23
  217. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  218. package/dist/tasks/tool-semaphore.js +96 -34
  219. package/dist/tasks/tool-semaphore.js.map +1 -1
  220. package/dist/upgrade.d.ts.map +1 -1
  221. package/dist/upgrade.js +37 -4
  222. package/dist/upgrade.js.map +1 -1
  223. package/dist/validation/doctor/checks.d.ts +17 -0
  224. package/dist/validation/doctor/checks.d.ts.map +1 -1
  225. package/dist/validation/doctor/checks.js +83 -0
  226. package/dist/validation/doctor/checks.js.map +1 -1
  227. package/package.json +13 -13
  228. package/schemas/project-context.schema.json +5 -1
@@ -40,9 +40,9 @@
40
40
  * @task T12983
41
41
  * @epic T12978
42
42
  */
43
- import { accessSync, existsSync, constants as fsConstants } from 'node:fs';
43
+ import { accessSync, existsSync, constants as fsConstants, realpathSync } from 'node:fs';
44
44
  import { homedir } from 'node:os';
45
- import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
45
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
46
46
  import { looksHeavy, resolveRunClass } from './run-class.js';
47
47
  /** Operators, longest first so a prefix never shadows a longer one. */
48
48
  const OPERATORS = [
@@ -71,6 +71,8 @@ const OPERATORS = [
71
71
  ')',
72
72
  ];
73
73
  const OPERATOR_START = new Set(['|', '&', ';', '<', '>', '(', ')']);
74
+ /** A `$name` or special parameter right after a `$` (sticky: matched at `lastIndex`). */
75
+ const PARAM_NAME = /[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#@*-]/y;
74
76
  const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
75
77
  /**
76
78
  * Lex `src` from `from` the way a POSIX shell tokenises it. In `paren` mode
@@ -93,6 +95,7 @@ function lexShell(src, from, mode) {
93
95
  expands: false,
94
96
  assignment: false,
95
97
  bare: true,
98
+ globby: false,
96
99
  };
97
100
  return cur;
98
101
  };
@@ -107,6 +110,7 @@ function lexShell(src, from, mode) {
107
110
  expands: cur.expands,
108
111
  assignment: cur.assignment,
109
112
  bare: cur.bare,
113
+ globby: cur.globby,
110
114
  };
111
115
  tokens.push(word);
112
116
  cur = null;
@@ -170,7 +174,9 @@ function lexShell(src, from, mode) {
170
174
  draft.bare = false;
171
175
  return j + 1;
172
176
  }
173
- const name = /^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#@*-])/.exec(src.slice(at + 1));
177
+ // Matched in place at `lastIndex` (sticky), anchored to the `$`.
178
+ PARAM_NAME.lastIndex = at + 1;
179
+ const name = PARAM_NAME.exec(src);
174
180
  if (name) {
175
181
  draft.value += `$${name[0]}`;
176
182
  draft.expands = true;
@@ -321,6 +327,9 @@ function lexShell(src, from, mode) {
321
327
  else {
322
328
  const d = begin(i);
323
329
  d.value += ch;
330
+ // zsh's EXTENDED_GLOB adds `^`, `~` and a mid-word `#` to `*?[` and braces.
331
+ if ('*?[{^~#'.includes(ch))
332
+ d.globby = true;
324
333
  i++;
325
334
  }
326
335
  }
@@ -635,11 +644,474 @@ export function planHeavyCommand(command, opts) {
635
644
  if (blockers.length > 0) {
636
645
  return { action: 'warn', reason: [...new Set(blockers)].join('; '), segments };
637
646
  }
638
- let rewritten = command;
639
- for (const edit of [...edits].sort((a, b) => b.at - a.at)) {
640
- rewritten = rewritten.slice(0, edit.at) + edit.text + rewritten.slice(edit.at);
647
+ // One pass over the sorted edits (linear; re-slicing per edit was quadratic).
648
+ const parts = [];
649
+ let at = 0;
650
+ for (const edit of [...edits].sort((a, b) => a.at - b.at)) {
651
+ parts.push(command.slice(at, edit.at), edit.text);
652
+ at = edit.at;
641
653
  }
642
- return { action: 'rewrite', command: rewritten, segments };
654
+ parts.push(command.slice(at));
655
+ return { action: 'rewrite', command: parts.join(''), segments };
656
+ }
657
+ // ---------------------------------------------------------------------------
658
+ // Claude Code pre-approval (T13124)
659
+ // ---------------------------------------------------------------------------
660
+ /** Claude Code prompts for any Bash command longer than this, whatever the rules. */
661
+ const CLAUDE_PARSE_LIMIT = 10_000;
662
+ /**
663
+ * Commands from Claude Code's built-in read-only set accepted without an allow
664
+ * rule, in the narrow form {@link claudePreApproval} checks. A subset: `find`,
665
+ * `diff`, `stat`, `du`, `which` and read-only `git` are left to the rules.
666
+ */
667
+ const CLAUDE_READ_ONLY = new Set(['cat', 'echo', 'pwd', 'head', 'tail', 'grep', 'wc', 'ls']);
668
+ /**
669
+ * Exact flag allowlists for the rule-free read-only commands (fail-closed,
670
+ * T13124 review MED-2b). getopt_long accepts any unique prefix (`grep --rec`
671
+ * is `--recursive`, `--der` `--dereference-recursive`, GNU `wc --files` is
672
+ * `--files0-from`), so a denylist of long flags cannot hold: only these exact
673
+ * spellings pass. Nothing here reads a file, recurses, follows a symlink or
674
+ * takes a non-numeric value; `ls` has no `-R`, `-L` or `-H`.
675
+ */
676
+ const READ_ONLY_FLAGS = {
677
+ cat: {
678
+ short: 'benstuv',
679
+ numericShort: '',
680
+ long: ['--number', '--number-nonblank', '--squeeze-blank', '--show-ends', '--show-tabs'],
681
+ numericLong: [],
682
+ bareCount: false,
683
+ positionals: 0,
684
+ },
685
+ head: {
686
+ short: 'qv',
687
+ numericShort: 'nc',
688
+ long: ['--quiet', '--silent', '--verbose'],
689
+ numericLong: ['--lines', '--bytes'],
690
+ bareCount: true,
691
+ positionals: 0,
692
+ },
693
+ tail: {
694
+ short: 'qv',
695
+ numericShort: 'nc',
696
+ long: ['--quiet', '--silent', '--verbose'],
697
+ numericLong: ['--lines', '--bytes'],
698
+ bareCount: true,
699
+ positionals: 0,
700
+ },
701
+ wc: {
702
+ short: 'clmw',
703
+ numericShort: '',
704
+ long: ['--bytes', '--chars', '--lines', '--words'],
705
+ numericLong: [],
706
+ bareCount: false,
707
+ positionals: 0,
708
+ },
709
+ ls: {
710
+ short: 'aAdFhilnpstu1',
711
+ numericShort: '',
712
+ long: ['--all', '--almost-all', '--human-readable'],
713
+ numericLong: [],
714
+ bareCount: false,
715
+ positionals: 0,
716
+ },
717
+ grep: {
718
+ short: 'EFGHIVabchinoqsvwxz',
719
+ numericShort: 'ABCm',
720
+ long: [
721
+ '--ignore-case',
722
+ '--invert-match',
723
+ '--count',
724
+ '--line-number',
725
+ '--word-regexp',
726
+ '--line-regexp',
727
+ '--only-matching',
728
+ '--quiet',
729
+ '--silent',
730
+ '--extended-regexp',
731
+ '--fixed-strings',
732
+ '--no-filename',
733
+ '--with-filename',
734
+ ],
735
+ numericLong: ['--max-count', '--after-context', '--before-context', '--context'],
736
+ bareCount: false,
737
+ positionals: 1,
738
+ },
739
+ };
740
+ /**
741
+ * Why the arguments of a rule-free read-only command could make it read a
742
+ * file, or `null` when they cannot (T13124 review MED-2: a project symlink
743
+ * `notes.txt -> /etc/hosts` made `cat notes.txt` a read outside the project).
744
+ * Fail-closed: these commands only filter stdin. Every flag must be in
745
+ * {@link READ_ONLY_FLAGS} exactly, a numeric value may follow only a flag that
746
+ * takes one, and only grep takes a positional (its one pattern). `echo`
747
+ * prints its arguments; `pwd` takes none. Anything unrecognised refuses.
748
+ */
749
+ function readOnlyArgsRefusal(cmd, args) {
750
+ if (cmd === 'echo')
751
+ return null;
752
+ if (cmd === 'pwd')
753
+ return args.length === 0 ? null : '`pwd` takes no arguments here';
754
+ const spec = READ_ONLY_FLAGS[cmd];
755
+ if (spec === undefined)
756
+ return `\`${cmd}\` is not a known stdin filter`;
757
+ const refuse = (arg) => `\`${cmd}\` takes \`${arg}\`, which is not in its stdin-filter allowlist`;
758
+ let positionals = 0;
759
+ for (let k = 0; k < args.length; k++) {
760
+ const arg = args[k];
761
+ const numericNext = () => {
762
+ const value = args[k + 1];
763
+ if (value === undefined || !/^\+?\d+$/.test(value))
764
+ return false;
765
+ k++;
766
+ return true;
767
+ };
768
+ // With POSIXLY_CORRECT, option parsing stops at the first operand, so a
769
+ // flag after grep's pattern would be a file name (review LOW).
770
+ if (positionals > 0 && arg.startsWith('-')) {
771
+ return `\`${cmd}\` takes a flag after its pattern (a file name under POSIXLY_CORRECT)`;
772
+ }
773
+ if (arg.startsWith('--')) {
774
+ if (spec.long.includes(arg))
775
+ continue;
776
+ const eq = arg.indexOf('=');
777
+ if (eq === -1
778
+ ? spec.numericLong.includes(arg) && numericNext()
779
+ : spec.numericLong.includes(arg.slice(0, eq)) && /^\d+$/.test(arg.slice(eq + 1))) {
780
+ continue;
781
+ }
782
+ return refuse(arg);
783
+ }
784
+ if (arg.startsWith('-') && arg.length > 1) {
785
+ if (spec.bareCount && /^-\d+$/.test(arg))
786
+ continue;
787
+ let ok = true;
788
+ for (let c = 1; c < arg.length; c++) {
789
+ const ch = arg[c];
790
+ if (spec.short.includes(ch))
791
+ continue;
792
+ if (spec.numericShort.includes(ch)) {
793
+ const glued = arg.slice(c + 1);
794
+ ok = glued === '' ? numericNext() : /^\d+$/.test(glued);
795
+ break;
796
+ }
797
+ ok = false;
798
+ break;
799
+ }
800
+ if (!ok)
801
+ return refuse(arg);
802
+ continue;
803
+ }
804
+ if (positionals < spec.positionals) {
805
+ positionals++;
806
+ continue;
807
+ }
808
+ return `\`${cmd}\` names a file (only stdin filters are accepted without a rule)`;
809
+ }
810
+ return null;
811
+ }
812
+ /** Operators that end a line Claude Code then treats as unparseable. */
813
+ const DANGLING = new Set(['&&', '||', '|', '|&']);
814
+ /**
815
+ * The only word shapes a pre-approved line may hold (fail-closed allowlist,
816
+ * T13124 review): runs of `[A-Za-z0-9_./:@%+=,-]`, `'single-quoted'`
817
+ * literals, and `"double-quoted"` literals with no `$`, backtick, backslash or
818
+ * `!`. Quoted text is printable ASCII only (no control characters, no
819
+ * newline, no non-ASCII). Everything else is refused: `$` in any form
820
+ * (variables, `$'…'`, `$"…"`, substitutions), backticks, backslash escapes,
821
+ * globs (`*?[`), braces, `~`, `!`, `#`, `^`, operators, whitespace and any
822
+ * byte outside printable ASCII. A word may not start with `=` (zsh EQUALS
823
+ * expansion). Each alternative consumes a single plain character or one whole
824
+ * quoted literal, so matching is linear (no nested quantifier; T13124 review
825
+ * HIGH-1: `[…]+` inside `(…)+` backtracked exponentially).
826
+ */
827
+ const STRICT_WORD = /^(?!=)(?:[A-Za-z0-9_./:@%+=,-]|'[\x20-\x26\x28-\x7e]*'|"[\x20\x23\x25-\x5b\x5d-\x5f\x61-\x7e]*")+$/;
828
+ /**
829
+ * zsh EQUALS expansion: a word-initial `=cmd` becomes the command's absolute
830
+ * path, and with MAGIC_EQUAL_SUBST so does `=cmd` after `=` or `:` in an
831
+ * `x=…` word. {@link STRICT_WORD} refuses the first; this the others.
832
+ */
833
+ const ZSH_EQUALS = /[=:]=/;
834
+ /**
835
+ * Operators a pre-approved line may hold: list and pipe separators, and the
836
+ * redirections {@link claudeSubcommands} narrows to `/dev/null` and file
837
+ * descriptors. Anything else (`&`, `|&`, subshell parentheses, heredocs,
838
+ * newlines, case terminators) is refused.
839
+ */
840
+ const STRICT_OPS = new Set([
841
+ '&&',
842
+ '||',
843
+ ';',
844
+ '|',
845
+ '>',
846
+ '>>',
847
+ '&>',
848
+ '&>>',
849
+ '>&',
850
+ '<&',
851
+ ]);
852
+ /**
853
+ * Why a lexed line falls outside the strict pre-approval grammar, or `null`
854
+ * when every token is in it: each word matches {@link STRICT_WORD}, each
855
+ * operator is in {@link STRICT_OPS}, and nothing but spaces sits between
856
+ * tokens (no tab, comment or line continuation). Fail-closed: a construct
857
+ * this grammar does not name is refused, whatever the lexer made of it.
858
+ */
859
+ function outsideStrictGrammar(lexed) {
860
+ let at = 0;
861
+ for (const t of lexed.tokens) {
862
+ if (!/^ *$/.test(lexed.src.slice(at, t.start))) {
863
+ return 'it holds text outside any word (a comment or line continuation)';
864
+ }
865
+ at = t.end;
866
+ if (t.kind === 'op') {
867
+ if (!STRICT_OPS.has(t.op))
868
+ return `it uses \`${t.op === '\n' ? 'newline' : t.op}\``;
869
+ continue;
870
+ }
871
+ const raw = lexed.src.slice(t.start, t.end);
872
+ // zsh EQUALS also fires on the quote-removed word (`""=ls` is `/bin/ls`;
873
+ // review LOW-1), so check the value as well as the raw text.
874
+ if (!STRICT_WORD.test(raw) ||
875
+ ZSH_EQUALS.test(raw) ||
876
+ t.value.startsWith('=') ||
877
+ ZSH_EQUALS.test(t.value)) {
878
+ return 'a word is not a plain or simply quoted literal (an expansion, escape, glob, brace or tilde)';
879
+ }
880
+ }
881
+ return /^ *$/.test(lexed.src.slice(at)) ? null : 'it holds text after the last word';
882
+ }
883
+ /**
884
+ * Whether a Claude Code Bash rule pattern (the text inside `Bash(…)`) matches
885
+ * one subcommand's text, as the permissions reference specifies: `*` stands for
886
+ * any text including spaces, a trailing `:*` equals a trailing ` *`, a trailing
887
+ * ` *` that is the rule's only wildcard also matches the bare command, and a
888
+ * rule without `*` matches one exact command. The bare `Bash` rule is `*`.
889
+ *
890
+ * @param pattern - the rule pattern, e.g. `pnpm test *` or `npm run test:*`.
891
+ * @param text - one subcommand as written.
892
+ *
893
+ * @example
894
+ * ```ts
895
+ * claudeBashRuleMatches('pnpm test *', 'pnpm test'); // true
896
+ * claudeBashRuleMatches('pnpm test:*', 'pnpm test --run'); // true
897
+ * claudeBashRuleMatches('ls *', 'lsof'); // false
898
+ * ```
899
+ */
900
+ export function claudeBashRuleMatches(pattern, text) {
901
+ const normalized = pattern.endsWith(':*') ? `${pattern.slice(0, -2)} *` : pattern;
902
+ if (wildcardMatch(normalized, text))
903
+ return true;
904
+ return (normalized.endsWith(' *') &&
905
+ normalized.indexOf('*') === normalized.length - 1 &&
906
+ text === normalized.slice(0, -2));
907
+ }
908
+ /**
909
+ * Whether `text` matches `pattern`, where `*` stands for any run of
910
+ * characters and everything else is literal. Iterative with one backtrack
911
+ * point, so it runs in O(pattern × text) at worst; a `RegExp` built from the
912
+ * pattern backtracks polynomially in the number of `*`s (T13124 review).
913
+ */
914
+ function wildcardMatch(pattern, text) {
915
+ let p = 0;
916
+ let t = 0;
917
+ let star = -1;
918
+ let mark = 0;
919
+ while (t < text.length) {
920
+ if (p < pattern.length && pattern[p] !== '*' && pattern[p] === text[t]) {
921
+ p++;
922
+ t++;
923
+ }
924
+ else if (p < pattern.length && pattern[p] === '*') {
925
+ star = p++;
926
+ mark = t;
927
+ }
928
+ else if (star !== -1) {
929
+ p = star + 1;
930
+ t = ++mark;
931
+ }
932
+ else {
933
+ return false;
934
+ }
935
+ }
936
+ while (p < pattern.length && pattern[p] === '*')
937
+ p++;
938
+ return p === pattern.length;
939
+ }
940
+ function inside(dir, path) {
941
+ const rel = relative(dir, path);
942
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
943
+ }
944
+ function canonicalPath(path) {
945
+ try {
946
+ return realpathSync(path);
947
+ }
948
+ catch {
949
+ return null;
950
+ }
951
+ }
952
+ /**
953
+ * Split one list element into pipeline stages, or a reason Claude Code might
954
+ * prompt for it regardless of rules: a redirection to anything but
955
+ * `/dev/null` or a file descriptor, or a `$` or backtick expansion.
956
+ */
957
+ function claudeSubcommands(src, tokens) {
958
+ const out = [];
959
+ let stage = [];
960
+ const flush = () => {
961
+ if (stage.length === 0)
962
+ return 'it has an empty pipeline stage';
963
+ const words = [];
964
+ for (let k = 0; k < stage.length; k++) {
965
+ const t = stage[k];
966
+ if (t.kind === 'word') {
967
+ if (t.expands)
968
+ return 'it expands a variable or command';
969
+ words.push(t);
970
+ continue;
971
+ }
972
+ const target = stage[k + 1];
973
+ if (target?.kind !== 'word' || target.expands)
974
+ return `it redirects (${t.op})`;
975
+ // The raw operator includes its fd prefix (`2>&`). Only stdout and stderr
976
+ // may be redirected: to /dev/null, or duplicated onto each other (review LOW).
977
+ const rawOp = src.slice(t.start, t.end);
978
+ const toNull = /^(?:[12]?>>?|&>>?)$/.test(rawOp) && target.value === '/dev/null';
979
+ const toFd = /^[12]?>&$/.test(rawOp) && /^[12]$/.test(target.value);
980
+ if (!toNull && !toFd)
981
+ return `it redirects (${t.op} ${target.value})`;
982
+ k++;
983
+ }
984
+ const first = stage[0];
985
+ const last = stage[stage.length - 1];
986
+ out.push({
987
+ words,
988
+ text: src.slice(first.start, last.end),
989
+ wordsText: words.map((w) => src.slice(w.start, w.end)).join(' '),
990
+ });
991
+ stage = [];
992
+ return null;
993
+ };
994
+ for (const t of tokens) {
995
+ if (t.kind === 'op' && (t.op === '|' || t.op === '|&')) {
996
+ const err = flush();
997
+ if (err !== null)
998
+ return err;
999
+ }
1000
+ else {
1001
+ stage.push(t);
1002
+ }
1003
+ }
1004
+ const err = flush();
1005
+ return err ?? out;
1006
+ }
1007
+ /**
1008
+ * Whether Claude Code would run `command` WITHOUT a prompt because the user's
1009
+ * Bash allow rules approve it: every subcommand (split at `&&`, `||`, `;`,
1010
+ * `|`, `|&` and newlines, as Claude Code splits) matches an allow rule, or is
1011
+ * a narrow read-only form Claude Code runs unprompted (`cd` into the working
1012
+ * directory, `tail -50`, `grep -v x`, …).
1013
+ *
1014
+ * The heavy-command hook uses this to rewrite a command in Claude Code's
1015
+ * default, acceptEdits, dontAsk and auto modes with `permissionDecision:
1016
+ * "allow"`: the user already approved the command, and the rewrite only makes
1017
+ * it queue for the machine-wide budget. So this check must never approve more
1018
+ * than Claude Code would. It is deliberately narrower: no wrapper stripping
1019
+ * (`timeout`, `nice`), no leading assignments, no expansions, no
1020
+ * substitutions, subshells, compound commands or background jobs, no
1021
+ * redirection except to `/dev/null` or a file descriptor, and both the stage
1022
+ * as written and its words alone must match the same rule. Anything else is
1023
+ * "not pre-approved" and the hook falls back to a warning. Deny and ask rules
1024
+ * are not checked here (the hook checks them, and Claude Code enforces them
1025
+ * on the rewritten command regardless).
1026
+ *
1027
+ * @param command - the command line as the agent wrote it.
1028
+ * @param allowPatterns - the patterns of the user's `Bash(…)` allow rules (`*` for a bare `Bash`).
1029
+ * @param opts - where the line starts, and Claude Code's working directory.
1030
+ * @returns approved, or the first reason it is not.
1031
+ *
1032
+ * @example
1033
+ * ```ts
1034
+ * claudePreApproval('pnpm test 2>&1 | tail -50', ['pnpm test *'], { cwd: '/repo', workingDir: '/repo' });
1035
+ * // { approved: true }
1036
+ * claudePreApproval('pnpm test && rm -rf dist', ['pnpm test *'], { cwd: '/repo', workingDir: '/repo' });
1037
+ * // { approved: false, reason: '`rm` is not approved by an allow rule' }
1038
+ * ```
1039
+ */
1040
+ export function claudePreApproval(command, allowPatterns, opts) {
1041
+ const no = (reason) => ({ approved: false, reason });
1042
+ if (command.length > CLAUDE_PARSE_LIMIT)
1043
+ return no('it is longer than Claude Code parses');
1044
+ const lexed = lexShell(command, 0, 'top');
1045
+ if (lexed.error !== null)
1046
+ return no('it does not parse');
1047
+ if (lexed.substitutions.length > 0)
1048
+ return no('it has a command or process substitution');
1049
+ // Fail-closed (review HIGH-1): every token must be a plain or simply quoted
1050
+ // literal, so the values checked below are exactly what the shell passes.
1051
+ // `$'\x2fetc'` is `/etc` to the shell but `\x2fetc` to the lexer.
1052
+ const outside = outsideStrictGrammar(lexed);
1053
+ if (outside !== null)
1054
+ return no(outside);
1055
+ const last = lexed.tokens[lexed.tokens.length - 1];
1056
+ if (last?.kind === 'op' && DANGLING.has(last.op))
1057
+ return no('it ends in an operator');
1058
+ const stages = [];
1059
+ for (const element of splitElements(lexed.tokens)) {
1060
+ if (element.nested || element.background) {
1061
+ return no('it has a subshell, compound command or background job');
1062
+ }
1063
+ const split = claudeSubcommands(lexed.src, element.tokens);
1064
+ if (typeof split === 'string')
1065
+ return no(split);
1066
+ stages.push(...split);
1067
+ }
1068
+ if (stages.length === 0)
1069
+ return no('it is empty');
1070
+ const name = (s) => s.words[0]?.value ?? '';
1071
+ if (stages.filter((s) => name(s) === 'cd').length > 1) {
1072
+ return no('it changes directory more than once');
1073
+ }
1074
+ const withGit = stages.some((s) => name(s) === 'git');
1075
+ const workingDir = canonicalPath(opts.workingDir);
1076
+ let cwd = opts.cwd;
1077
+ for (const stage of stages) {
1078
+ const ruled = allowPatterns.some((p) => claudeBashRuleMatches(p, stage.text) && claudeBashRuleMatches(p, stage.wordsText));
1079
+ if (ruled)
1080
+ continue;
1081
+ const cmd = name(stage);
1082
+ const readOnly = cmd === 'cd' || CLAUDE_READ_ONLY.has(cmd);
1083
+ if (!readOnly || stage.words.some((w) => w.assignment)) {
1084
+ return no(`\`${cmd}\` is not approved by an allow rule`);
1085
+ }
1086
+ if (workingDir === null)
1087
+ return no('the working directory cannot be resolved');
1088
+ if (cmd === 'cd') {
1089
+ const target = stage.words[1]?.value;
1090
+ if (stage.words.length !== 2 ||
1091
+ target === undefined ||
1092
+ target === '-' ||
1093
+ target.startsWith('~') ||
1094
+ stage.words[1]?.globby === true) {
1095
+ return no('its `cd` is not a plain path');
1096
+ }
1097
+ if (withGit)
1098
+ return no('it runs git after a `cd`');
1099
+ const next = resolve(cwd, target);
1100
+ const real = canonicalPath(next);
1101
+ if (!inside(opts.workingDir, next) || real === null || !inside(workingDir, real)) {
1102
+ return no('its `cd` leaves the working directory');
1103
+ }
1104
+ cwd = next;
1105
+ continue;
1106
+ }
1107
+ const here = canonicalPath(cwd);
1108
+ if (here === null || !inside(workingDir, here))
1109
+ return no('it runs outside the working directory');
1110
+ const refused = readOnlyArgsRefusal(cmd, stage.words.slice(1).map((w) => w.value));
1111
+ if (refused !== null)
1112
+ return no(refused);
1113
+ }
1114
+ return { approved: true };
643
1115
  }
644
1116
  // ---------------------------------------------------------------------------
645
1117
  // Mode, availability, pressure