@gajae-code/coding-agent 0.18.12 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -3,15 +3,210 @@
3
3
  *
4
4
  * This check runs before bash spawns anything. It is intentionally limited to
5
5
  * tmux input verbs; inspection and control of another pane remain available.
6
+ * It is best-effort static analysis, not a security boundary: arbitrary
7
+ * interpreter one-liners, shell variable indirection, and commands launched by
8
+ * the Python/eval tools are outside its coverage.
6
9
  */
7
10
  import * as fs from "node:fs";
8
11
  import * as path from "node:path";
9
12
 
10
13
  const INPUT_VERBS = new Set(["send-keys", "paste-buffer", "send-prefix"]);
11
- const SHELL_RUNNERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "busybox"]);
14
+ const SHELL_RUNNERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "ash", "hush", "busybox"]);
15
+ const SHELL_STDIN_SCRIPT_PATHS = new Set([
16
+ "/dev/stdin",
17
+ "/dev/fd/0",
18
+ "/proc/self/fd/0",
19
+ "/proc/thread-self/fd/0",
20
+ "/proc/$$/fd/0",
21
+ ]);
12
22
  const COMMAND_WRAPPERS = new Set(["eval", "exec", "command", "builtin", "nohup", "time"]);
23
+ const BUILTIN_COMMAND_WRAPPERS = new Set([".", "command", "eval", "exec", "source"]);
24
+ const NON_EXECUTING_COREUTILS_WRAPPERS = new Set([
25
+ "env",
26
+ "nice",
27
+ "nohup",
28
+ "setsid",
29
+ "stdbuf",
30
+ "time",
31
+ "timeout",
32
+ "xargs",
33
+ ]);
34
+ const NON_EXECUTING_COREUTILS_OPTIONS = new Set(["--help", "--version"]);
35
+ const NON_EXECUTING_WRAPPER_OPTIONS: Record<string, Set<string>> = {
36
+ setsid: new Set(["-h", "-V"]),
37
+ };
38
+ const NON_EXECUTING_SUDO_OPTIONS = new Set([
39
+ "-e",
40
+ "--edit",
41
+ "-K",
42
+ "-l",
43
+ "--list",
44
+ "--remove-timestamp",
45
+ "-v",
46
+ "--validate",
47
+ "-V",
48
+ "--version",
49
+ "--help",
50
+ ]);
51
+ const NON_EXECUTING_ENV_OPTIONS = new Set(["--help", "--null", "--version"]);
52
+ const ENV_SIGNAL_OPTIONS = new Set(["--block-signal", "--default-signal", "--ignore-signal", "--list-signal-handling"]);
53
+ const ASSIGNMENT_AWARE_WRAPPERS = new Set(["env", "sudo"]);
13
54
  const CONTROL_WORDS = new Set(["then", "do", "else", "fi", "done", "esac"]);
14
55
  const MAX_INDIRECTION_DEPTH = 4;
56
+ const BASH_TIME_WRAPPER_SPEC: WrapperSpec = { optionsWithArg: new Set(), flagOnlyOptions: new Set(["-p"]) };
57
+
58
+ // Wrapper commands and their options that take an argument.
59
+ // These are used to properly skip over option arguments so the next word
60
+ // in command position is recognized as the actual command.
61
+ interface WrapperSpec {
62
+ // Option flags that take exactly one argument (e.g., "-u NAME")
63
+ optionsWithArg: Set<string>;
64
+ // Flag-only options that do not consume the next argument (e.g., sudo "-s", "-i")
65
+ flagOnlyOptions?: Set<string>;
66
+ // Special positional argument handling (e.g., timeout takes a DURATION)
67
+ hasPositionalArg?: boolean;
68
+ }
69
+
70
+ // Long options for bash/sh that take arguments (e.g., --rcfile FILE, --init-file FILE)
71
+ const SHELL_LONG_OPTIONS_WITH_ARG = new Set([
72
+ "--rcfile", // bash --rcfile FILE
73
+ "--init-file", // bash --init-file FILE (same as --rcfile)
74
+ ]);
75
+ const SHELL_POSITIVE_SHORT_OPTIONS = new Set("abefhiklmnprstuvxBCEHPT");
76
+
77
+ // Options that take arguments for long-option forms (e.g., env --unset=VAR, xargs --file=FILE)
78
+ const LONG_OPTIONS_WITH_ARG = new Set([
79
+ "--unset", // env --unset VAR (takes variable name)
80
+ ]);
81
+
82
+ // Per-wrapper long-option specifications
83
+ const WRAPPER_LONG_OPTIONS: Record<string, Record<string, boolean>> = {
84
+ env: {
85
+ "--unset": true, // env --unset=VAR or --unset VAR
86
+ "--chdir": true, // env --chdir=DIR (takes directory)
87
+ "--split-string": true, // env -S/--split-string (takes command string)
88
+ "--argv0": true, // env --argv0=ARG or --argv0 ARG
89
+ "--block-signal": false,
90
+ "--default-signal": false,
91
+ "--ignore-signal": false,
92
+ "--list-signal-handling": false,
93
+ "--help": false,
94
+ "--null": false,
95
+ "--version": false,
96
+ },
97
+ sudo: {
98
+ "--chdir": true,
99
+ "--chroot": true,
100
+ "--close-from": true,
101
+ "--command-timeout": true,
102
+ "--group": true,
103
+ "--host": true,
104
+ "--login-class": true,
105
+ "--other-user": true,
106
+ "--prompt": true,
107
+ "--role": true,
108
+ "--type": true,
109
+ "--user": true,
110
+ "--preserve-env": false,
111
+ },
112
+ timeout: {
113
+ "--signal": true, // timeout --signal=SIGNAL (takes signal name)
114
+ "--kill-after": true, // timeout --kill-after=TIME
115
+ "--preserve-status": false, // timeout --preserve-status (flag-only)
116
+ "--verbose": false, // timeout --verbose (flag-only)
117
+ "--foreground": false, // timeout --foreground (flag-only)
118
+ "--help": false,
119
+ "--version": false,
120
+ },
121
+ nice: {
122
+ "--adjustment": true, // nice --adjustment=N (takes adjustment value)
123
+ "--help": false,
124
+ "--version": false,
125
+ },
126
+ time: {
127
+ "--format": true,
128
+ "--output": true,
129
+ "--append": false,
130
+ "--portability": false,
131
+ "--quiet": false,
132
+ "--verbose": false,
133
+ "--help": false,
134
+ "--version": false,
135
+ },
136
+ stdbuf: {
137
+ "--input": true, // stdbuf --input=MODE
138
+ "--output": true, // stdbuf --output=MODE
139
+ "--error": true, // stdbuf --error=MODE
140
+ "--help": false,
141
+ "--version": false,
142
+ },
143
+ xargs: {
144
+ "--null": false, // xargs --null (flag-only)
145
+ "--max-args": true, // xargs --max-args N
146
+ "--max-lines": true, // xargs --max-lines MAX-LINES
147
+ "--arg-file": true, // xargs --arg-file FILE
148
+ "--delimiter": true, // xargs --delimiter DELIM
149
+ "--eof": false, // xargs --eof[=STRING]; the optional value is attached-only
150
+ "--max-procs": true, // xargs --max-procs N
151
+ "--max-chars": true, // xargs --max-chars SIZE (previously --size)
152
+ "--size": true, // xargs --size BYTES
153
+ "--process-slot-var": true, // xargs --process-slot-var VAR
154
+ "--replace": false, // xargs --replace (flag-only, same as -I)
155
+ "--verbose": false, // xargs --verbose (flag-only)
156
+ "--no-run-if-empty": false, // xargs --no-run-if-empty (flag-only)
157
+ "--exit": false, // xargs --exit (flag-only)
158
+ "--null-input": false, // xargs --null-input (flag-only, same as -0)
159
+ "--help": false,
160
+ "--version": false,
161
+ },
162
+ nohup: {
163
+ "--help": false,
164
+ "--version": false,
165
+ },
166
+ setsid: {
167
+ "--ctty": false,
168
+ "--fork": false,
169
+ "--wait": false,
170
+ "--help": false,
171
+ "--version": false,
172
+ },
173
+ };
174
+
175
+ const COMMAND_WRAPPERS_WITH_ARGS: Record<string, WrapperSpec> = {
176
+ env: {
177
+ optionsWithArg: new Set(["-u", "-C", "-S", "-a", "-P"]),
178
+ },
179
+ sudo: {
180
+ optionsWithArg: new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T", "-R", "-c"]),
181
+ flagOnlyOptions: new Set(["-s", "-i"]),
182
+ },
183
+ exec: {
184
+ optionsWithArg: new Set(["-a"]),
185
+ },
186
+ timeout: {
187
+ optionsWithArg: new Set(["-s", "-k"]), // -s SIGNAL, -k KILL_SIGNAL; -v is flag-only
188
+ flagOnlyOptions: new Set(["-v", "-p"]), // -v flag-only, -p flag-only
189
+ hasPositionalArg: true, // timeout DURATION command
190
+ },
191
+ nice: {
192
+ optionsWithArg: new Set(["-n"]),
193
+ },
194
+ time: {
195
+ optionsWithArg: new Set(["-f", "-o"]),
196
+ flagOnlyOptions: new Set(["-a", "-p", "-q", "-v"]),
197
+ },
198
+ xargs: {
199
+ optionsWithArg: new Set(["-E", "-I", "-J", "-L", "-n", "-P", "-R", "-d", "-s", "-a"]), // -s SIZE, -a FILE
200
+ flagOnlyOptions: new Set(["-t", "-x", "-0"]),
201
+ },
202
+ stdbuf: {
203
+ optionsWithArg: new Set(["-i", "-o", "-e"]),
204
+ },
205
+ setsid: {
206
+ optionsWithArg: new Set(),
207
+ flagOnlyOptions: new Set(["-c", "-w"]),
208
+ },
209
+ };
15
210
 
16
211
  export interface TmuxSelfInjectionResult {
17
212
  block: boolean;
@@ -27,6 +222,8 @@ export type TmuxPaneResolver = (options: {
27
222
  export interface TmuxSelfInjectionOptions {
28
223
  env?: Record<string, string | undefined>;
29
224
  cwd?: string;
225
+ /** Effective Bash environment for locating shell-runner scripts; never used for tmux identity. */
226
+ executionEnv?: Record<string, string | undefined>;
30
227
  resolvePaneId?: TmuxPaneResolver;
31
228
  }
32
229
 
@@ -34,6 +231,57 @@ interface Token {
34
231
  text: string;
35
232
  quoted: boolean;
36
233
  commandStart: boolean;
234
+ segment: number;
235
+ pipeline: number;
236
+ subshellDepth: number;
237
+ conditionallyExecuted?: boolean;
238
+ stdinRedirectOrder?: number;
239
+ stdinRedirectSource?: "here-document" | "here-string" | "other";
240
+ isRedirectionTarget?: boolean;
241
+ redirectionFd?: number;
242
+ redirectionIsInput?: boolean;
243
+ redirectionIsDuplication?: boolean;
244
+ commandSubstitutions?: string[];
245
+ executionSuppressed?: boolean;
246
+ hasLiteralShellExpansion?: boolean;
247
+ isWrappedCommand?: boolean; // true if this token is the actual command being wrapped by a wrapper like 'command', 'env', 'sudo'
248
+ wrappedBy?: string;
249
+ }
250
+
251
+ interface HereDocument {
252
+ kind: "here-document" | "here-string";
253
+ expandsCommandSubstitutions: boolean;
254
+ delimiter: string;
255
+ delimiterQuoted: boolean;
256
+ stripTabs: boolean;
257
+ segment: number;
258
+ pipeline: number;
259
+ subshellDepth: number;
260
+ activeDescriptors: Set<number>;
261
+ stdin: boolean;
262
+ redirectOrder: number;
263
+ stdinRedirectOrders: Set<number>;
264
+ body: string;
265
+ }
266
+
267
+ interface ShellScript {
268
+ segment: number;
269
+ path: string;
270
+ fileDescriptor?: number;
271
+ cwd?: string;
272
+ cwdUnknown?: boolean;
273
+ pathUnknown?: boolean;
274
+ pathUnknownReason?: string;
275
+ }
276
+
277
+ interface CommandEnvironmentValue {
278
+ value: string | undefined;
279
+ assignment?: Token;
280
+ }
281
+
282
+ interface TokenizeResult {
283
+ tokens: Token[];
284
+ hereDocuments: HereDocument[];
37
285
  }
38
286
 
39
287
  function isSeparator(ch: string): boolean {
@@ -44,187 +292,2689 @@ function isAssignment(text: string): boolean {
44
292
  return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(text);
45
293
  }
46
294
 
295
+ function isNegativeNumber(text: string): boolean {
296
+ return /^-\d+/.test(text); // e.g., -5, -10, etc.
297
+ }
298
+
299
+ function isPositiveShellOption(text: string): boolean {
300
+ return (
301
+ text.length > 1 && text[0] === "+" && [...text.slice(1)].every(option => SHELL_POSITIVE_SHORT_OPTIONS.has(option))
302
+ );
303
+ }
304
+
305
+ function shellOptionDisablesExecution(option: string): boolean {
306
+ if (option === "--dump-strings" || option === "--dump-po-strings") return true;
307
+ if (!option.startsWith("-") || option.startsWith("--") || /[\s;&|()<>$`]/.test(option)) return false;
308
+ for (const flag of option.slice(1)) {
309
+ if (flag === "n" || flag === "D") return true;
310
+ if (flag === "O" || flag === "o") return false;
311
+ }
312
+ return false;
313
+ }
314
+
315
+ function shellOptionEnablesExecution(option: string): boolean {
316
+ return isPositiveShellOption(option) && option.slice(1).includes("n");
317
+ }
318
+
319
+ function isShellExpansionCharacter(character: string): boolean {
320
+ return "$`*?[]~".includes(character);
321
+ }
322
+
323
+ function isLongOption(text: string): boolean {
324
+ return text.startsWith("--");
325
+ }
326
+
327
+ function commandName(text: string): string {
328
+ const separator = text.lastIndexOf("/");
329
+ return separator < 0 ? text : text.slice(separator + 1);
330
+ }
331
+
332
+ function matchingWrapperLongOption(wrapperName: string, option: string): string | undefined {
333
+ const options = WRAPPER_LONG_OPTIONS[wrapperName];
334
+ if (!options) return undefined;
335
+ if (Object.hasOwn(options, option)) return option;
336
+ const matches = Object.keys(options).filter(candidate => candidate.startsWith(option));
337
+ return matches.length === 1 ? matches[0] : undefined;
338
+ }
339
+
340
+ function isNonExecutingEnvOption(option: string): boolean {
341
+ if (option.startsWith("--")) {
342
+ const optionName = matchingWrapperLongOption("env", option.split("=")[0]);
343
+ return optionName !== undefined && NON_EXECUTING_ENV_OPTIONS.has(optionName);
344
+ }
345
+ if (!option.startsWith("-") || option === "-") return false;
346
+ for (let index = 1; index < option.length; index++) {
347
+ const shortOption = `-${option[index]}`;
348
+ if (shortOption === "-0") return true;
349
+ if (COMMAND_WRAPPERS_WITH_ARGS.env.optionsWithArg.has(shortOption)) break;
350
+ }
351
+ return false;
352
+ }
353
+
354
+ function wrapperOptionTakesNextArgument(wrapper: { name: string; spec: WrapperSpec }, option: string): boolean {
355
+ if (isLongOption(option)) {
356
+ const baseOption = option.split("=")[0];
357
+ if (option.includes("=")) return false;
358
+ const wrapperOptions = WRAPPER_LONG_OPTIONS[wrapper.name];
359
+ const matchingOption = matchingWrapperLongOption(wrapper.name, baseOption);
360
+ if (matchingOption !== undefined && wrapperOptions) return wrapperOptions[matchingOption];
361
+ return LONG_OPTIONS_WITH_ARG.has(baseOption);
362
+ }
363
+
364
+ for (let index = 1; index < option.length; index++) {
365
+ const shortOption = `-${option[index]}`;
366
+ const flagOnly = wrapper.spec.flagOnlyOptions?.has(shortOption) ?? false;
367
+ if (!flagOnly && wrapper.spec.optionsWithArg.has(shortOption)) return index === option.length - 1;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ function envSplitStringOption(option: string): { payload: string } | { payloadPending: true } | undefined {
373
+ if (option.startsWith("--")) {
374
+ const equalsIndex = option.indexOf("=");
375
+ const optionName = matchingWrapperLongOption("env", equalsIndex < 0 ? option : option.slice(0, equalsIndex));
376
+ if (optionName !== "--split-string") return undefined;
377
+ return equalsIndex < 0 ? { payloadPending: true } : { payload: option.slice(equalsIndex + 1) };
378
+ }
379
+ if (!option.startsWith("-") || option.length < 2) return undefined;
380
+ for (let index = 1; index < option.length; index++) {
381
+ const character = option[index];
382
+ if (character === "S") {
383
+ const payload = option.slice(index + 1);
384
+ return payload.length > 0 ? { payload } : { payloadPending: true };
385
+ }
386
+ if (character === "u" || character === "C" || character === "a") break;
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ function splitEnvSplitString(splitString: string): string[] {
392
+ const parts: string[] = [];
393
+ let current = "";
394
+ let argumentStarted = false;
395
+ let quoted = false;
396
+ let quoteChar: "'" | '"' = "'";
397
+
398
+ for (let index = 0; index < splitString.length; index++) {
399
+ const character = splitString[index];
400
+ if (character === "\\" && splitString[index + 1] !== undefined) {
401
+ const next = splitString[index + 1];
402
+ if (next === "_") {
403
+ index++;
404
+ if (quoted) {
405
+ current += " ";
406
+ argumentStarted = true;
407
+ } else {
408
+ if (argumentStarted) parts.push(current);
409
+ current = "";
410
+ argumentStarted = false;
411
+ }
412
+ } else if (next === quoteChar || next === "'" || next === '"' || next === "\\") {
413
+ current += next;
414
+ argumentStarted = true;
415
+ index++;
416
+ } else {
417
+ current += character;
418
+ argumentStarted = true;
419
+ }
420
+ } else if (!quoted && (character === "'" || character === '"')) {
421
+ quoted = true;
422
+ quoteChar = character;
423
+ argumentStarted = true;
424
+ } else if (quoted && character === quoteChar) {
425
+ quoted = false;
426
+ } else if (!quoted && /\s/.test(character)) {
427
+ if (argumentStarted) parts.push(current);
428
+ current = "";
429
+ argumentStarted = false;
430
+ } else {
431
+ current += character;
432
+ argumentStarted = true;
433
+ }
434
+ }
435
+ if (argumentStarted) parts.push(current);
436
+ return parts;
437
+ }
438
+
439
+ function envSplitStringIsNonExecuting(splitString: string, depth = 0): boolean {
440
+ if (depth > MAX_INDIRECTION_DEPTH) return false;
441
+ const parts = splitEnvSplitString(splitString);
442
+ let skipNext = false;
443
+ let optionsTerminated = false;
444
+
445
+ for (let index = 0; index < parts.length; index++) {
446
+ const part = parts[index];
447
+ if (part === undefined) continue;
448
+ if (skipNext) {
449
+ skipNext = false;
450
+ continue;
451
+ }
452
+ if (optionsTerminated) return false;
453
+ if (part === "--") {
454
+ optionsTerminated = true;
455
+ continue;
456
+ }
457
+ if (isNonExecutingEnvOption(part)) return true;
458
+
459
+ if (part === "-S" || part === "--split-string") {
460
+ const nested = parts[index + 1];
461
+ if (nested !== undefined && envSplitStringIsNonExecuting(nested, depth + 1)) return true;
462
+ index++;
463
+ continue;
464
+ }
465
+ if (part.startsWith("-S") && part.length > 2) {
466
+ if (envSplitStringIsNonExecuting(part.slice(2), depth + 1)) return true;
467
+ continue;
468
+ }
469
+ if (part.startsWith("--split-string=")) {
470
+ if (envSplitStringIsNonExecuting(part.slice("--split-string=".length), depth + 1)) return true;
471
+ continue;
472
+ }
473
+ if (part.startsWith("--")) {
474
+ if (wrapperOptionTakesNextArgument({ name: "env", spec: COMMAND_WRAPPERS_WITH_ARGS.env }, part))
475
+ skipNext = true;
476
+ continue;
477
+ }
478
+ if (part.startsWith("-") && part !== "-") {
479
+ for (let optionIndex = 1; optionIndex < part.length; optionIndex++) {
480
+ const shortOption = `-${part[optionIndex]}`;
481
+ if (shortOption === "-S") {
482
+ const nested = part.slice(optionIndex + 1) || parts[index + 1];
483
+ if (nested !== undefined && envSplitStringIsNonExecuting(nested, depth + 1)) return true;
484
+ if (part.length === optionIndex + 1) index++;
485
+ break;
486
+ }
487
+ if (COMMAND_WRAPPERS_WITH_ARGS.env.optionsWithArg.has(shortOption)) {
488
+ if (optionIndex === part.length - 1) skipNext = true;
489
+ break;
490
+ }
491
+ }
492
+ continue;
493
+ }
494
+ if (!isAssignment(part)) break;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ function wrapperMayInvokeShellBuiltins(name: string, assignmentAware: boolean): boolean {
500
+ return (
501
+ name === "builtin" ||
502
+ name === "command" ||
503
+ name === "eval" ||
504
+ name === "exec" ||
505
+ (name === "time" && assignmentAware)
506
+ );
507
+ }
508
+
509
+ function builtinWrapperRejectsOption(name: string, option: string): boolean {
510
+ if (option === "--") return false;
511
+ if (name === "builtin" || name === "eval") return true;
512
+ if (name === "command") return !/^-[pVv]+$/.test(option);
513
+ if (name === "exec") {
514
+ for (const flag of option.slice(1)) {
515
+ if (flag === "a") return false;
516
+ if (flag !== "c" && flag !== "l") return true;
517
+ }
518
+ }
519
+ return false;
520
+ }
521
+
522
+ function decodeAnsiCQuotedString(command: string, start: number): { value: string; end: number } | undefined {
523
+ if (command[start] !== "$" || command[start + 1] !== "'") return undefined;
524
+ let value = "";
525
+ for (let index = start + 2; index < command.length; index++) {
526
+ const character = command[index];
527
+ if (character === "'") return { value, end: index };
528
+ if (character !== "\\") {
529
+ value += character;
530
+ continue;
531
+ }
532
+ if (index + 1 >= command.length) return undefined;
533
+ const escaped = command[++index];
534
+ const simpleEscape = ansiCSimpleEscape(escaped);
535
+ if (simpleEscape !== undefined) {
536
+ value += simpleEscape;
537
+ continue;
538
+ }
539
+ if (escaped === "c" && index + 1 < command.length) {
540
+ value += String.fromCharCode(command[++index].charCodeAt(0) & 0x1f);
541
+ continue;
542
+ }
543
+ if (escaped === "x") {
544
+ let digits = "";
545
+ while (digits.length < 2 && isHexDigit(command[index + 1] ?? "")) digits += command[++index];
546
+ value += digits ? String.fromCharCode(Number.parseInt(digits, 16)) : "x";
547
+ continue;
548
+ }
549
+ if (escaped === "u" || escaped === "U") {
550
+ const width = escaped === "u" ? 4 : 8;
551
+ const digits = command.slice(index + 1, index + 1 + width);
552
+ if (digits.length === width && [...digits].every(isHexDigit)) {
553
+ const codePoint = Number.parseInt(digits, 16);
554
+ if (codePoint <= 0x10ffff) value += String.fromCodePoint(codePoint);
555
+ index += width;
556
+ } else {
557
+ value += escaped;
558
+ }
559
+ continue;
560
+ }
561
+ if (escaped >= "0" && escaped <= "7") {
562
+ let digits = escaped;
563
+ while (digits.length < 3 && command[index + 1] >= "0" && command[index + 1] <= "7") {
564
+ digits += command[++index];
565
+ }
566
+ value += String.fromCharCode(Number.parseInt(digits, 8));
567
+ continue;
568
+ }
569
+ value += escaped;
570
+ }
571
+ return undefined;
572
+ }
573
+
574
+ function ansiCSimpleEscape(character: string): string | undefined {
575
+ switch (character) {
576
+ case "a":
577
+ return "\x07";
578
+ case "b":
579
+ return "\b";
580
+ case "e":
581
+ case "E":
582
+ return "\x1b";
583
+ case "f":
584
+ return "\f";
585
+ case "n":
586
+ return "\n";
587
+ case "r":
588
+ return "\r";
589
+ case "t":
590
+ return "\t";
591
+ case "v":
592
+ return "\v";
593
+ case "\\":
594
+ return "\\";
595
+ case "'":
596
+ return "'";
597
+ case '"':
598
+ return '"';
599
+ case "?":
600
+ return "?";
601
+ default:
602
+ return undefined;
603
+ }
604
+ }
605
+
606
+ function isHexDigit(character: string): boolean {
607
+ return (
608
+ (character >= "0" && character <= "9") ||
609
+ (character >= "a" && character <= "f") ||
610
+ (character >= "A" && character <= "F")
611
+ );
612
+ }
613
+
47
614
  /** Small quote-aware lexer. Unknown shell syntax is represented conservatively. */
48
- function tokenize(command: string): Token[] {
615
+ function tokenize(command: string): TokenizeResult {
49
616
  const tokens: Token[] = [];
617
+ const hereDocuments: HereDocument[] = [];
50
618
  let text = "";
51
619
  let quoted = false;
52
620
  let quote: "'" | '"' | undefined;
53
621
  let atCommandStart = true;
54
- let wrapperPending = false;
55
- let previous = "";
622
+ let segment = 0;
623
+ let pipeline = 0;
624
+ let subshellDepth = 0;
625
+ let executionSuppressed = false;
626
+ let hasLiteralShellExpansion = false;
627
+ let commandSubstitutions: string[] = [];
628
+ const conditionalSegments = new Set<number>();
629
+ let wrapperCommand:
630
+ | {
631
+ name: string;
632
+ spec: WrapperSpec;
633
+ optionsTerminated: boolean;
634
+ assignmentAware: boolean;
635
+ positionalArgConsumed?: boolean;
636
+ envSplitOperandPending?: boolean;
637
+ }
638
+ | undefined;
639
+ let skipNextArg = false;
640
+ let positionalsPending = 0;
641
+ let redirectionTargetPending = false;
642
+ let redirectionTargetIsHereDoc = false;
643
+ let redirectionTargetIsHereString = false;
644
+ let redirectionTargetExpandsCommandSubstitutions = false;
645
+ let redirectionTargetStripTabs = false;
646
+ let redirectionTargetIsInput = false;
647
+ let redirectionTargetIsDuplication = false;
648
+ let redirectionTargetFd: number | undefined;
649
+ let currentTokenIsPendingOperand = false;
650
+ const stdinRedirects = new Map<number, { order: number; source: "here-document" | "here-string" | "other" }>();
651
+ const activeHereDocuments = new Map<string, HereDocument>();
652
+ const hereDocDelimiters: HereDocument[] = [];
653
+ let activeHereDoc: HereDocument | undefined;
654
+ let hereDocLine = "";
655
+ let inComment = false;
656
+ let commandContinuesAfterLineBreak = false;
56
657
 
57
658
  const flush = () => {
58
- if (text.length === 0) return;
59
- const token: Token = { text, quoted, commandStart: atCommandStart };
60
- tokens.push(token);
659
+ if (text.length === 0 && !quoted) return;
660
+
661
+ if (redirectionTargetPending) {
662
+ const descriptor = redirectionTargetFd ?? 0;
663
+ const isIdentityDescriptorDuplication =
664
+ redirectionTargetIsDuplication && /^\d+$/.test(text) && Number(text) === descriptor;
665
+ const descriptorKey = `${segment}:${descriptor}`;
666
+ if (!isIdentityDescriptorDuplication) {
667
+ const previousHereDocument = activeHereDocuments.get(descriptorKey);
668
+ previousHereDocument?.activeDescriptors.delete(descriptor);
669
+ activeHereDocuments.delete(descriptorKey);
670
+ const duplicatedDescriptor = redirectionTargetIsDuplication ? /^(\d+)(-)?$/.exec(text) : undefined;
671
+ if (duplicatedDescriptor) {
672
+ const sourceDescriptor = Number(duplicatedDescriptor[1]);
673
+ const sourceKey = `${segment}:${sourceDescriptor}`;
674
+ const sourceHereDocument = activeHereDocuments.get(sourceKey);
675
+ if (sourceHereDocument) {
676
+ sourceHereDocument.activeDescriptors.add(descriptor);
677
+ activeHereDocuments.set(descriptorKey, sourceHereDocument);
678
+ if (duplicatedDescriptor[2]) {
679
+ sourceHereDocument.activeDescriptors.delete(sourceDescriptor);
680
+ activeHereDocuments.delete(sourceKey);
681
+ }
682
+ }
683
+ }
684
+ }
685
+ const redirectedHereDocument = activeHereDocuments.get(descriptorKey);
686
+
687
+ let redirectOrder: number | undefined;
688
+ const isIdentityInputDuplication =
689
+ redirectionTargetIsInput &&
690
+ redirectionTargetIsDuplication &&
691
+ /^0+$/.test(text) &&
692
+ (redirectionTargetFd === undefined || redirectionTargetFd === 0);
693
+ if (
694
+ redirectionTargetIsInput &&
695
+ !isIdentityInputDuplication &&
696
+ (redirectionTargetFd === undefined || redirectionTargetFd === 0)
697
+ ) {
698
+ redirectOrder = (stdinRedirects.get(segment)?.order ?? 0) + 1;
699
+ stdinRedirects.set(segment, {
700
+ order: redirectOrder,
701
+ source: redirectionTargetIsHereDoc
702
+ ? "here-document"
703
+ : redirectionTargetIsHereString
704
+ ? "here-string"
705
+ : (redirectedHereDocument?.kind ?? "other"),
706
+ });
707
+ for (const token of tokens) {
708
+ if (token.segment === segment && token.commandStart) {
709
+ token.stdinRedirectOrder = redirectOrder;
710
+ token.stdinRedirectSource = redirectionTargetIsHereDoc
711
+ ? "here-document"
712
+ : redirectionTargetIsHereString
713
+ ? "here-string"
714
+ : (redirectedHereDocument?.kind ?? "other");
715
+ }
716
+ }
717
+ if (redirectedHereDocument) redirectedHereDocument.stdinRedirectOrders.add(redirectOrder);
718
+ }
719
+ tokens.push({
720
+ text,
721
+ quoted,
722
+ commandStart: false,
723
+ segment,
724
+ pipeline,
725
+ subshellDepth,
726
+ ...(conditionalSegments.has(segment) && { conditionallyExecuted: true }),
727
+ isRedirectionTarget: true,
728
+ redirectionFd: descriptor,
729
+ redirectionIsInput: redirectionTargetIsInput,
730
+ redirectionIsDuplication: redirectionTargetIsDuplication,
731
+ ...(commandSubstitutions.length > 0 && { commandSubstitutions }),
732
+ ...(executionSuppressed && { executionSuppressed }),
733
+ ...(hasLiteralShellExpansion && { hasLiteralShellExpansion }),
734
+ });
735
+ if (redirectionTargetIsHereDoc || redirectionTargetIsHereString) {
736
+ const hereDocument: HereDocument = {
737
+ kind: redirectionTargetIsHereDoc ? "here-document" : "here-string",
738
+ expandsCommandSubstitutions: redirectionTargetExpandsCommandSubstitutions,
739
+ delimiter: text,
740
+ delimiterQuoted: quoted,
741
+ stripTabs: redirectionTargetStripTabs,
742
+ segment,
743
+ pipeline,
744
+ subshellDepth,
745
+ activeDescriptors: new Set([descriptor]),
746
+ stdin: redirectionTargetFd === undefined || redirectionTargetFd === 0,
747
+ redirectOrder: redirectOrder ?? 0,
748
+ stdinRedirectOrders: new Set(),
749
+ body: redirectionTargetIsHereString ? text : "",
750
+ };
751
+ if (redirectionTargetIsHereDoc) hereDocDelimiters.push(hereDocument);
752
+ hereDocuments.push(hereDocument);
753
+ activeHereDocuments.set(descriptorKey, hereDocument);
754
+ }
755
+ text = "";
756
+ quoted = false;
757
+ redirectionTargetPending = false;
758
+ redirectionTargetIsHereDoc = false;
759
+ redirectionTargetIsHereString = false;
760
+ redirectionTargetExpandsCommandSubstitutions = false;
761
+ redirectionTargetStripTabs = false;
762
+ redirectionTargetIsInput = false;
763
+ redirectionTargetIsDuplication = false;
764
+ redirectionTargetFd = undefined;
765
+ currentTokenIsPendingOperand = false;
766
+ hasLiteralShellExpansion = false;
767
+ commandSubstitutions = [];
768
+ return;
769
+ }
770
+
771
+ let commandStart = atCommandStart;
772
+ let isWrappedCommand = false;
773
+ let wrappedBy: string | undefined;
774
+
61
775
  if (atCommandStart) {
62
- if (isAssignment(text) || CONTROL_WORDS.has(text) || wrapperPending) {
63
- atCommandStart = true;
64
- } else if (COMMAND_WRAPPERS.has(text)) {
65
- atCommandStart = true;
66
- wrapperPending = true;
67
- } else if (text === "sudo" || text === "env") {
776
+ // Normalize wrapper name: extract basename from path (e.g., /usr/bin/env -> env)
777
+ const baseName = text.includes("/") ? text.split("/").pop() || text : text;
778
+ const newWrapper = COMMAND_WRAPPERS_WITH_ARGS[baseName];
779
+ const isBuiltinWrapper = COMMAND_WRAPPERS.has(baseName);
780
+
781
+ if (CONTROL_WORDS.has(text)) {
782
+ // Control words reset wrapper state (but not if we're already in a wrapper)
783
+ if (!wrapperCommand) {
784
+ wrapperCommand = undefined;
785
+ skipNextArg = false;
786
+ positionalsPending = 0;
787
+ }
68
788
  atCommandStart = true;
69
- wrapperPending = true;
789
+ } else if (isAssignment(text)) {
790
+ // Assignments at command start
791
+ if (!wrapperCommand) {
792
+ // Not in a wrapper - this is a simple assignment at command start
793
+ atCommandStart = true;
794
+ }
795
+ // If in a wrapper, keep wrapper active and don't change atCommandStart
796
+ } else if (newWrapper) {
797
+ // Track this wrapper for its arguments (use baseName for consistent lookup)
798
+ const wrapperSpec = baseName === "time" && !text.includes("/") ? BASH_TIME_WRAPPER_SPEC : newWrapper;
799
+ wrapperCommand = {
800
+ name: baseName,
801
+ spec: wrapperSpec,
802
+ optionsTerminated: false,
803
+ assignmentAware: ASSIGNMENT_AWARE_WRAPPERS.has(baseName) || (baseName === "time" && !text.includes("/")),
804
+ };
805
+ skipNextArg = false;
806
+ positionalsPending = wrapperSpec.hasPositionalArg ? 1 : 0;
807
+ atCommandStart = false; // Now in wrapper argument mode
808
+ } else if (isBuiltinWrapper) {
809
+ // Built-in wrappers
810
+ wrapperCommand = {
811
+ name: baseName,
812
+ spec: { optionsWithArg: new Set() },
813
+ optionsTerminated: false,
814
+ assignmentAware: false,
815
+ };
816
+ skipNextArg = false;
817
+ positionalsPending = 0;
818
+ atCommandStart = false; // Now in wrapper argument mode
819
+ } else if (wrapperCommand) {
820
+ // This is the command being wrapped - mark it as a command start
821
+ wrappedBy = wrapperCommand.name;
822
+ wrapperCommand = undefined;
823
+ skipNextArg = false;
824
+ positionalsPending = 0;
825
+ commandStart = true; // The wrapped command is a command start
826
+ atCommandStart = false;
70
827
  } else {
828
+ // Regular command
71
829
  atCommandStart = false;
72
- wrapperPending = false;
73
830
  }
74
- } else if (wrapperPending) {
75
- // A wrapper's options and option arguments are not command names.
76
- // The first ordinary word after them is handled by the special case
77
- // below when the next token is seen.
78
- atCommandStart = false;
831
+ } else {
832
+ // Not at command start
833
+ if (currentTokenIsPendingOperand) {
834
+ if (wrapperCommand?.envSplitOperandPending) {
835
+ if (reconstructCommandFromEnvSplit(text)?.length) wrapperCommand = undefined;
836
+ else if (envSplitStringIsNonExecuting(text)) {
837
+ wrapperCommand = undefined;
838
+ executionSuppressed = true;
839
+ }
840
+ }
841
+ skipNextArg = false;
842
+ } else if (skipNextArg && wrapperCommand?.envSplitOperandPending) {
843
+ if (reconstructCommandFromEnvSplit(text)?.length) wrapperCommand = undefined;
844
+ else if (envSplitStringIsNonExecuting(text)) {
845
+ wrapperCommand = undefined;
846
+ executionSuppressed = true;
847
+ }
848
+ skipNextArg = false;
849
+ } else if (wrapperCommand?.positionalArgConsumed) {
850
+ const outerWrapper = wrapperCommand;
851
+ const wrappedName = text.includes("/") ? text.split("/").pop() || text : text;
852
+ const nestedWrapper = COMMAND_WRAPPERS_WITH_ARGS[wrappedName];
853
+ const nestedBuiltinWrapper = COMMAND_WRAPPERS.has(wrappedName);
854
+ if (nestedWrapper) {
855
+ wrapperCommand = {
856
+ name: wrappedName,
857
+ spec: nestedWrapper,
858
+ optionsTerminated: false,
859
+ assignmentAware: ASSIGNMENT_AWARE_WRAPPERS.has(wrappedName),
860
+ };
861
+ positionalsPending = nestedWrapper.hasPositionalArg ? 1 : 0;
862
+ } else if (
863
+ nestedBuiltinWrapper &&
864
+ wrapperMayInvokeShellBuiltins(outerWrapper.name, outerWrapper.assignmentAware)
865
+ ) {
866
+ wrapperCommand = {
867
+ name: wrappedName,
868
+ spec: { optionsWithArg: new Set() },
869
+ optionsTerminated: false,
870
+ assignmentAware: false,
871
+ };
872
+ positionalsPending = 0;
873
+ } else {
874
+ wrappedBy = outerWrapper.name;
875
+ wrapperCommand = undefined;
876
+ positionalsPending = 0;
877
+ isWrappedCommand = true;
878
+ }
879
+ if (wrapperCommand) wrappedBy = outerWrapper.name;
880
+ skipNextArg = false;
881
+ commandStart = true;
882
+ } else if (wrapperCommand && text === "--") {
883
+ wrapperCommand.optionsTerminated = true;
884
+ skipNextArg = false;
885
+ } else if (wrapperCommand?.optionsTerminated) {
886
+ if (positionalsPending > 0) {
887
+ positionalsPending--;
888
+ } else if (wrapperCommand.name === "env" && isAssignment(text)) {
889
+ // env still accepts NAME=VALUE assignments after its -- terminator.
890
+ commandStart = false;
891
+ } else {
892
+ const wrappedName = text.includes("/") ? text.split("/").pop() || text : text;
893
+ const nestedWrapper = COMMAND_WRAPPERS_WITH_ARGS[wrappedName];
894
+ if (wrapperCommand.name === "builtin" && !BUILTIN_COMMAND_WRAPPERS.has(wrappedName)) {
895
+ wrapperCommand = undefined;
896
+ skipNextArg = false;
897
+ positionalsPending = 0;
898
+ } else if (nestedWrapper) {
899
+ wrapperCommand = {
900
+ name: wrappedName,
901
+ spec: nestedWrapper,
902
+ optionsTerminated: false,
903
+ assignmentAware: ASSIGNMENT_AWARE_WRAPPERS.has(wrappedName),
904
+ };
905
+ skipNextArg = false;
906
+ positionalsPending = nestedWrapper.hasPositionalArg ? 1 : 0;
907
+ commandStart = true;
908
+ } else if (COMMAND_WRAPPERS.has(wrappedName)) {
909
+ wrapperCommand = {
910
+ name: wrappedName,
911
+ spec: { optionsWithArg: new Set() },
912
+ optionsTerminated: false,
913
+ assignmentAware: false,
914
+ };
915
+ skipNextArg = false;
916
+ positionalsPending = 0;
917
+ commandStart = true;
918
+ } else {
919
+ wrappedBy = wrapperCommand.name;
920
+ wrapperCommand = undefined;
921
+ skipNextArg = false;
922
+ positionalsPending = 0;
923
+ commandStart = true;
924
+ isWrappedCommand = true;
925
+ }
926
+ }
927
+ } else if (
928
+ text.startsWith("-") &&
929
+ (!isNegativeNumber(text) || (wrapperCommand?.name === "env" && text === "-0"))
930
+ ) {
931
+ // This is an option (but not a negative number like -5)
932
+ // Don't touch skipNextArg yet; it will be consumed by the next argument
933
+ const wrapperLongOption =
934
+ text.startsWith("--") && wrapperCommand !== undefined
935
+ ? matchingWrapperLongOption(wrapperCommand.name, text.split("=")[0])
936
+ : undefined;
937
+ const nonExecutingSudoMode = wrapperCommand?.name === "sudo" && NON_EXECUTING_SUDO_OPTIONS.has(text);
938
+ const nonExecutingEnvMode = wrapperCommand?.name === "env" && isNonExecutingEnvOption(text);
939
+ const invalidBuiltinOption =
940
+ wrapperCommand !== undefined && builtinWrapperRejectsOption(wrapperCommand.name, text);
941
+ const nonExecutingCoreutilsMode =
942
+ wrapperCommand !== undefined &&
943
+ NON_EXECUTING_COREUTILS_WRAPPERS.has(wrapperCommand.name) &&
944
+ (NON_EXECUTING_COREUTILS_OPTIONS.has(text) ||
945
+ (wrapperLongOption !== undefined && NON_EXECUTING_COREUTILS_OPTIONS.has(wrapperLongOption)) ||
946
+ NON_EXECUTING_WRAPPER_OPTIONS[wrapperCommand.name]?.has(text) === true);
947
+ if (nonExecutingSudoMode || nonExecutingEnvMode || invalidBuiltinOption || nonExecutingCoreutilsMode) {
948
+ // These modes never execute the supplied command.
949
+ executionSuppressed = true;
950
+ wrapperCommand = undefined;
951
+ skipNextArg = false;
952
+ positionalsPending = 0;
953
+ } else if (wrapperCommand) {
954
+ const envSplitOption = wrapperCommand.name === "env" ? envSplitStringOption(text) : undefined;
955
+ if (envSplitOption && "payload" in envSplitOption) {
956
+ if (reconstructCommandFromEnvSplit(envSplitOption.payload)?.length) wrapperCommand = undefined;
957
+ else if (envSplitStringIsNonExecuting(envSplitOption.payload)) {
958
+ wrapperCommand = undefined;
959
+ executionSuppressed = true;
960
+ }
961
+ skipNextArg = false;
962
+ } else if (envSplitOption) {
963
+ skipNextArg = true;
964
+ wrapperCommand.envSplitOperandPending = true;
965
+ } else {
966
+ skipNextArg = wrapperOptionTakesNextArgument(wrapperCommand, text);
967
+ }
968
+ }
969
+ } else if (skipNextArg) {
970
+ // This is an argument to an option (e.g., -n 5 or -n -5)
971
+ // Skip it regardless of whether it looks like an option
972
+ skipNextArg = false;
973
+ } else if (positionalsPending > 0) {
974
+ // This is a positional argument like timeout's DURATION
975
+ positionalsPending--;
976
+ if (wrapperCommand) wrapperCommand.positionalArgConsumed = true;
977
+ } else if (isAssignment(text) && wrapperCommand?.assignmentAware) {
978
+ // Keep assignment-aware wrappers active for commands such as 'env FOO=x tmux'.
979
+ commandStart = false;
980
+ } else if (isAssignment(text) && wrapperCommand && !wrapperCommand.assignmentAware) {
981
+ // In non-assignment-aware wrappers, VAR=value tries to be executed as a command
982
+ // This means we've hit the end of the wrapper and found the wrapped command
983
+ wrappedBy = wrapperCommand.name;
984
+ wrapperCommand = undefined;
985
+ commandStart = true;
986
+ } else if (wrapperCommand && !text.startsWith("-") && !isAssignment(text)) {
987
+ // We're in a wrapper and this is not an option or assignment
988
+ // Check if this is itself a wrapper (nested wrappers like 'env sudo')
989
+ // Normalize the name to handle path-qualified commands
990
+ const nestedBaseName = text.includes("/") ? text.split("/").pop() || text : text;
991
+ const nestedWrapper = COMMAND_WRAPPERS_WITH_ARGS[nestedBaseName];
992
+ const isNestedBuiltinWrapper = COMMAND_WRAPPERS.has(nestedBaseName);
993
+
994
+ if (wrapperCommand.name === "builtin" && !BUILTIN_COMMAND_WRAPPERS.has(nestedBaseName)) {
995
+ // `builtin` only invokes shell builtins. In particular, `builtin tmux`
996
+ // does not execute the external tmux binary.
997
+ wrapperCommand = undefined;
998
+ skipNextArg = false;
999
+ positionalsPending = 0;
1000
+ } else if (nestedWrapper) {
1001
+ // Nested wrapper found - set it up as the new wrapper (use baseName)
1002
+ wrapperCommand = {
1003
+ name: nestedBaseName,
1004
+ spec: nestedWrapper,
1005
+ optionsTerminated: false,
1006
+ assignmentAware: ASSIGNMENT_AWARE_WRAPPERS.has(nestedBaseName),
1007
+ };
1008
+
1009
+ skipNextArg = false;
1010
+ positionalsPending = nestedWrapper.hasPositionalArg ? 1 : 0;
1011
+ commandStart = true; // The wrapper itself is still a command start
1012
+ } else if (
1013
+ isNestedBuiltinWrapper &&
1014
+ wrapperMayInvokeShellBuiltins(wrapperCommand.name, wrapperCommand.assignmentAware)
1015
+ ) {
1016
+ // Nested built-in wrapper found
1017
+ wrapperCommand = {
1018
+ name: nestedBaseName,
1019
+ spec: { optionsWithArg: new Set() },
1020
+ optionsTerminated: false,
1021
+ assignmentAware: false,
1022
+ };
1023
+ skipNextArg = false;
1024
+ positionalsPending = 0;
1025
+ commandStart = true; // The wrapper itself is still a command start
1026
+ } else {
1027
+ // Not a wrapper - this is the final wrapped command
1028
+ wrappedBy = wrapperCommand.name;
1029
+ wrapperCommand = undefined;
1030
+ skipNextArg = false;
1031
+ commandStart = true; // The wrapped command is a command start
1032
+ isWrappedCommand = true; // Track that this is a wrapped command
1033
+ }
1034
+ }
79
1035
  }
1036
+
1037
+ const stdinRedirect = stdinRedirects.get(segment);
1038
+ const token: Token = {
1039
+ text,
1040
+ quoted,
1041
+ commandStart,
1042
+ segment,
1043
+ pipeline,
1044
+ subshellDepth,
1045
+ ...(conditionalSegments.has(segment) && { conditionallyExecuted: true }),
1046
+ ...(stdinRedirect && { stdinRedirectOrder: stdinRedirect.order, stdinRedirectSource: stdinRedirect.source }),
1047
+ ...(commandSubstitutions.length > 0 && { commandSubstitutions }),
1048
+ ...(executionSuppressed && { executionSuppressed }),
1049
+ ...(hasLiteralShellExpansion && { hasLiteralShellExpansion }),
1050
+ ...(isWrappedCommand && { isWrappedCommand }),
1051
+ ...(wrappedBy && { wrappedBy }),
1052
+ };
1053
+ tokens.push(token);
1054
+
80
1055
  text = "";
81
1056
  quoted = false;
1057
+ hasLiteralShellExpansion = false;
1058
+ commandSubstitutions = [];
1059
+ currentTokenIsPendingOperand = false;
82
1060
  };
83
1061
 
84
1062
  for (let index = 0; index < command.length; index++) {
85
1063
  const ch = command[index];
1064
+ if (activeHereDoc) {
1065
+ if (ch === "\n") {
1066
+ const line = activeHereDoc.stripTabs ? hereDocLine.replace(/^\t+/, "") : hereDocLine;
1067
+ if (line.replace(/\r$/, "") === activeHereDoc.delimiter) {
1068
+ activeHereDoc = hereDocDelimiters.shift();
1069
+ } else {
1070
+ activeHereDoc.body += `${line}\n`;
1071
+ }
1072
+ hereDocLine = "";
1073
+ } else {
1074
+ hereDocLine += ch;
1075
+ }
1076
+ continue;
1077
+ }
1078
+ if (inComment) {
1079
+ if (ch !== "\n") continue;
1080
+ inComment = false;
1081
+ }
1082
+ if (redirectionTargetPending && ((ch === "$" && command[index + 1] === "(") || ch === "`") && quote !== "'") {
1083
+ redirectionTargetExpandsCommandSubstitutions = true;
1084
+ }
1085
+ if (quote !== "'" && !(redirectionTargetPending && redirectionTargetIsHereDoc)) {
1086
+ if (quote === undefined && ch === "$" && command[index + 1] === "'") {
1087
+ const ansiCQuote = decodeAnsiCQuotedString(command, index);
1088
+ if (ansiCQuote) {
1089
+ text += ansiCQuote.value;
1090
+ for (const character of ansiCQuote.value) {
1091
+ if (isShellExpansionCharacter(character)) {
1092
+ hasLiteralShellExpansion = true;
1093
+ break;
1094
+ }
1095
+ }
1096
+ quoted = true;
1097
+ index = ansiCQuote.end;
1098
+ continue;
1099
+ }
1100
+ }
1101
+ if (ch === "$" && command[index + 1] === "(" && command[index + 2] !== "(") {
1102
+ const end = commandSubstitutionEnd(command, index);
1103
+ if (end !== undefined) {
1104
+ commandSubstitutions.push(command.slice(index + 2, end));
1105
+ text += command.slice(index, end + 1);
1106
+ index = end;
1107
+ continue;
1108
+ }
1109
+ }
1110
+ if (ch === "`") {
1111
+ const end = backtickSubstitutionEnd(command, index);
1112
+ if (end !== undefined) {
1113
+ commandSubstitutions.push(command.slice(index + 1, end).replaceAll("\\`", "`"));
1114
+ text += command.slice(index, end + 1);
1115
+ index = end;
1116
+ continue;
1117
+ }
1118
+ }
1119
+ }
86
1120
  if (quote !== undefined) {
87
1121
  if (ch === quote) {
88
1122
  quote = undefined;
89
1123
  continue;
90
1124
  }
1125
+ if (quote === "'" && isShellExpansionCharacter(ch)) hasLiteralShellExpansion = true;
91
1126
  if (quote === '"' && ch === "\\" && index + 1 < command.length) {
1127
+ if (command[index + 1] === "\n") {
1128
+ index++;
1129
+ continue;
1130
+ }
1131
+ if (isShellExpansionCharacter(command[index + 1])) hasLiteralShellExpansion = true;
92
1132
  text += command[++index];
93
1133
  continue;
94
1134
  }
95
1135
  text += ch;
96
1136
  continue;
97
1137
  }
98
- if (ch === "'" || ch === '"') {
99
- quote = ch;
100
- quoted = true;
1138
+ if (ch === "'" || ch === '"') {
1139
+ commandContinuesAfterLineBreak = false;
1140
+ if (text.length === 0 && !quoted && skipNextArg) currentTokenIsPendingOperand = true;
1141
+ quote = ch;
1142
+ quoted = true;
1143
+ continue;
1144
+ }
1145
+ if (ch === "\\" && index + 1 < command.length) {
1146
+ if (command[index + 1] === "\n") {
1147
+ index++;
1148
+ continue;
1149
+ }
1150
+ commandContinuesAfterLineBreak = false;
1151
+ if (text.length === 0 && !quoted && skipNextArg) currentTokenIsPendingOperand = true;
1152
+ if (isShellExpansionCharacter(command[index + 1])) hasLiteralShellExpansion = true;
1153
+ text += command[++index];
1154
+ quoted = true;
1155
+ continue;
1156
+ }
1157
+ if (ch === "\n") {
1158
+ if (commandContinuesAfterLineBreak) continue;
1159
+ commandContinuesAfterLineBreak = false;
1160
+ flush();
1161
+ segment++;
1162
+ pipeline++;
1163
+ atCommandStart = true;
1164
+ wrapperCommand = undefined;
1165
+ skipNextArg = false;
1166
+ positionalsPending = 0;
1167
+ executionSuppressed = false;
1168
+ redirectionTargetPending = false;
1169
+ redirectionTargetIsHereDoc = false;
1170
+ redirectionTargetIsHereString = false;
1171
+ redirectionTargetExpandsCommandSubstitutions = false;
1172
+ redirectionTargetStripTabs = false;
1173
+ redirectionTargetIsInput = false;
1174
+ redirectionTargetIsDuplication = false;
1175
+ redirectionTargetFd = undefined;
1176
+ currentTokenIsPendingOperand = false;
1177
+ if (hereDocDelimiters.length > 0) activeHereDoc = hereDocDelimiters.shift();
1178
+ continue;
1179
+ }
1180
+ if (/\s/.test(ch)) {
1181
+ flush();
1182
+ continue;
1183
+ }
1184
+ if (ch === "&" && command[index + 1] === ">") {
1185
+ commandContinuesAfterLineBreak = false;
1186
+ if (/^\d+$/.test(text)) text = "";
1187
+ else flush();
1188
+ index++;
1189
+ if (command[index + 1] === ">") index++;
1190
+ if (command[index + 1] === "|") index++;
1191
+ redirectionTargetPending = true;
1192
+ redirectionTargetIsHereDoc = false;
1193
+ redirectionTargetIsHereString = false;
1194
+ redirectionTargetStripTabs = false;
1195
+ redirectionTargetIsInput = false;
1196
+ redirectionTargetIsDuplication = false;
1197
+ redirectionTargetFd = undefined;
1198
+ continue;
1199
+ }
1200
+ if (ch === "<" || ch === ">") {
1201
+ commandContinuesAfterLineBreak = false;
1202
+ if (/^\d+$/.test(text)) {
1203
+ redirectionTargetFd = Number(text);
1204
+ text = "";
1205
+ } else {
1206
+ flush();
1207
+ redirectionTargetFd = undefined;
1208
+ }
1209
+ const isHereDoc = ch === "<" && command[index + 1] === "<" && command[index + 2] !== "<";
1210
+ const isHereString = ch === "<" && command[index + 1] === "<" && command[index + 2] === "<";
1211
+ while (command[index + 1] === ch) index++;
1212
+ const stripTabs = isHereDoc && command[index + 1] === "-";
1213
+ if (stripTabs) index++;
1214
+ redirectionTargetIsDuplication = command[index + 1] === "&";
1215
+ if (redirectionTargetIsDuplication || (ch === ">" && command[index + 1] === "|")) index++;
1216
+ redirectionTargetPending = true;
1217
+ redirectionTargetIsHereDoc = isHereDoc;
1218
+ redirectionTargetIsHereString = isHereString;
1219
+ redirectionTargetStripTabs = stripTabs;
1220
+ redirectionTargetIsInput = ch === "<";
1221
+ continue;
1222
+ }
1223
+ if (isSeparator(ch)) {
1224
+ commandContinuesAfterLineBreak =
1225
+ ch === "|" || (ch === "&" && (command[index - 1] === "&" || command[index - 1] === "|"));
1226
+ flush();
1227
+ segment++;
1228
+ if ((ch === "&" && command[index - 1] === "&") || (ch === "|" && command[index - 1] === "|")) {
1229
+ conditionalSegments.add(segment);
1230
+ }
1231
+ if (ch === "(") subshellDepth++;
1232
+ else if (ch === ")") subshellDepth = Math.max(0, subshellDepth - 1);
1233
+ if (
1234
+ (ch !== "(" && ch !== ")" && ch !== "|" && !(ch === "&" && command[index - 1] === "|")) ||
1235
+ (ch === "|" && command[index - 1] === "|")
1236
+ ) {
1237
+ pipeline++;
1238
+ }
1239
+ atCommandStart = true;
1240
+ wrapperCommand = undefined;
1241
+ skipNextArg = false;
1242
+ positionalsPending = 0;
1243
+ executionSuppressed = false;
1244
+ redirectionTargetPending = false;
1245
+ redirectionTargetIsHereDoc = false;
1246
+ redirectionTargetIsHereString = false;
1247
+ redirectionTargetStripTabs = false;
1248
+ redirectionTargetIsInput = false;
1249
+ redirectionTargetIsDuplication = false;
1250
+ redirectionTargetFd = undefined;
1251
+ currentTokenIsPendingOperand = false;
1252
+ continue;
1253
+ }
1254
+ if (ch === "#" && text.length === 0 && !quoted) {
1255
+ inComment = true;
1256
+ continue;
1257
+ }
1258
+
1259
+ commandContinuesAfterLineBreak = false;
1260
+ if (text.length === 0 && !quoted && skipNextArg) currentTokenIsPendingOperand = true;
1261
+ text += ch;
1262
+ }
1263
+ flush();
1264
+ if (activeHereDoc && hereDocLine.length > 0) {
1265
+ const line = activeHereDoc.stripTabs ? hereDocLine.replace(/^\t+/, "") : hereDocLine;
1266
+ if (line.replace(/\r$/, "") !== activeHereDoc.delimiter) activeHereDoc.body += line;
1267
+ }
1268
+ return { tokens, hereDocuments };
1269
+ }
1270
+
1271
+ interface Invocation {
1272
+ verb: string | undefined;
1273
+ args: string[];
1274
+ socketArgs: string[];
1275
+ socket?: { flag: "-L" | "-S"; value: string };
1276
+ shellCommands: string[];
1277
+ nonExecuting: boolean;
1278
+ }
1279
+
1280
+ const TMUX_GLOBAL_OPTIONS_WITH_ARGUMENT = new Set(["c", "f", "L", "S", "T"]);
1281
+
1282
+ function parseInvocation(tokens: Token[]): Invocation | undefined {
1283
+ let index = 0;
1284
+ const socketArgs: string[] = [];
1285
+ let socket: Invocation["socket"];
1286
+ const shellCommands: string[] = [];
1287
+ while (index < tokens.length && !tokens[index].commandStart) {
1288
+ const option = tokens[index].text;
1289
+ if (option === "--") {
1290
+ index++;
1291
+ break;
1292
+ }
1293
+ if (option === "--help" || option === "--version") {
1294
+ return { verb: undefined, args: [], socketArgs, socket, shellCommands, nonExecuting: true };
1295
+ }
1296
+ if (!option.startsWith("-") || option.startsWith("--") || option === "-") break;
1297
+
1298
+ let separateOperand = false;
1299
+ for (let optionIndex = 1; optionIndex < option.length; optionIndex++) {
1300
+ const flag = option[optionIndex];
1301
+ if (flag === "h" || flag === "V") {
1302
+ return { verb: undefined, args: [], socketArgs, socket, shellCommands, nonExecuting: true };
1303
+ }
1304
+ if (!TMUX_GLOBAL_OPTIONS_WITH_ARGUMENT.has(flag)) continue;
1305
+
1306
+ let value = option.slice(optionIndex + 1);
1307
+ if (value.length === 0) {
1308
+ const operand = tokens[index + 1];
1309
+ if (!operand || operand.commandStart) return undefined;
1310
+ value = operand.text;
1311
+ separateOperand = true;
1312
+ }
1313
+ if (flag === "L" || flag === "S") {
1314
+ const socketFlag = `-${flag}` as "-L" | "-S";
1315
+ socket = { flag: socketFlag, value };
1316
+ socketArgs.push(socketFlag, value);
1317
+ }
1318
+ if (flag === "c") {
1319
+ shellCommands.length = 0;
1320
+ shellCommands.push(value);
1321
+ }
1322
+ break;
1323
+ }
1324
+ index += separateOperand ? 2 : 1;
1325
+ }
1326
+ const verb = tokens[index];
1327
+ if (!verb || verb.commandStart) {
1328
+ return { verb: undefined, args: [], socketArgs, socket, shellCommands, nonExecuting: false };
1329
+ }
1330
+ const args: string[] = [];
1331
+ for (index++; index < tokens.length && !tokens[index].commandStart; index++) args.push(tokens[index].text);
1332
+ return { verb: verb.text, args, socketArgs, socket, shellCommands, nonExecuting: false };
1333
+ }
1334
+
1335
+ function targetFromArgs(args: string[]): string | undefined {
1336
+ for (let index = 0; index < args.length; index++) {
1337
+ if (args[index] === "-t") return args[index + 1];
1338
+ if (args[index].startsWith("-t") && args[index].length > 2) return args[index].slice(2);
1339
+ }
1340
+ return undefined;
1341
+ }
1342
+
1343
+ /**
1344
+ * Reconstruct the full command line from an env -S split string.
1345
+ * env -S will split the string and process the resulting tokens as env's own arguments.
1346
+ * Keep leading variable assignments in the returned argv so recursive analysis can
1347
+ * account for their socket override.
1348
+ */
1349
+ function reconstructCommandFromEnvSplit(splitString: string, depth = 0): string[] | undefined {
1350
+ if (depth > MAX_INDIRECTION_DEPTH) return undefined;
1351
+
1352
+ const parts = splitEnvSplitString(splitString);
1353
+
1354
+ const assignments: string[] = [];
1355
+ const envShortOptionsWithArg = new Set(["-u", "-C", "-a", "-P"]);
1356
+ const envShortFlagOptions = new Set(["-0", "-i", "-v"]);
1357
+ let skipNext = false;
1358
+ let optionsTerminated = false;
1359
+ let commandStartIdx = -1;
1360
+
1361
+ for (let i = 0; i < parts.length; i++) {
1362
+ const part = parts[i];
1363
+
1364
+ if (skipNext) {
1365
+ skipNext = false;
1366
+ continue;
1367
+ }
1368
+ if (optionsTerminated) {
1369
+ commandStartIdx = i;
1370
+ break;
1371
+ }
1372
+ if (part === "--") {
1373
+ optionsTerminated = true;
1374
+ continue;
1375
+ }
1376
+ if (isNonExecutingEnvOption(part)) return undefined;
1377
+ const equalsIndex = part.indexOf("=");
1378
+ const optionName = matchingWrapperLongOption("env", equalsIndex < 0 ? part : part.slice(0, equalsIndex));
1379
+ const longOptionName = equalsIndex < 0 ? part : part.slice(0, equalsIndex);
1380
+ if (ENV_SIGNAL_OPTIONS.has(longOptionName)) continue;
1381
+
1382
+ let splitString: string | undefined;
1383
+ let splitOptionLength = 0;
1384
+ if (part === "-S" || optionName === "--split-string") {
1385
+ splitString = equalsIndex < 0 ? parts[i + 1] : part.slice(equalsIndex + 1);
1386
+ splitOptionLength = equalsIndex < 0 ? 2 : 1;
1387
+ } else if (part.startsWith("-S") && part.length > 2) {
1388
+ splitString = part.slice(2);
1389
+ splitOptionLength = 1;
1390
+ }
1391
+ let shortBundleHandled = false;
1392
+ if (part.startsWith("-") && !part.startsWith("--") && part.length > 2) {
1393
+ shortBundleHandled = true;
1394
+ for (let optionIndex = 1; optionIndex < part.length; optionIndex++) {
1395
+ const option = `-${part[optionIndex]}`;
1396
+ if (option === "-S") {
1397
+ splitString = part.slice(optionIndex + 1) || parts[i + 1];
1398
+ splitOptionLength = part.length - optionIndex > 1 ? 1 : 2;
1399
+ break;
1400
+ }
1401
+ if (envShortOptionsWithArg.has(option)) {
1402
+ if (optionIndex === part.length - 1) skipNext = true;
1403
+ break;
1404
+ }
1405
+ if (!envShortFlagOptions.has(option)) {
1406
+ shortBundleHandled = false;
1407
+ break;
1408
+ }
1409
+ }
1410
+ }
1411
+ if (splitOptionLength > 0) {
1412
+ if (splitString === undefined) return undefined;
1413
+ const nested = reconstructCommandFromEnvSplit(splitString, depth + 1);
1414
+ if (!nested) return undefined;
1415
+ parts.splice(i, splitOptionLength, ...nested);
1416
+ i = -1;
1417
+ assignments.length = 0;
1418
+ skipNext = false;
1419
+ optionsTerminated = false;
1420
+ continue;
1421
+ }
1422
+ if (shortBundleHandled) continue;
1423
+
1424
+ if (
1425
+ part === "-" ||
1426
+ part === "-i" ||
1427
+ part === "--ignore-environment" ||
1428
+ part === "-v" ||
1429
+ part === "--debug" ||
1430
+ part === "-0"
1431
+ ) {
1432
+ continue;
1433
+ }
1434
+ if (envShortOptionsWithArg.has(part)) {
1435
+ skipNext = true;
1436
+ continue;
1437
+ }
1438
+ if (
1439
+ (part.startsWith("-u") && part.length > 2) ||
1440
+ (part.startsWith("-C") && part.length > 2) ||
1441
+ (part.startsWith("-a") && part.length > 2)
1442
+ ) {
1443
+ continue;
1444
+ }
1445
+ if (part.startsWith("--")) {
1446
+ const eqIdx = part.indexOf("=");
1447
+ const optName = eqIdx >= 0 ? part.substring(0, eqIdx) : part;
1448
+ if (optName === "--unset" || optName === "--chdir" || optName === "--argv0") {
1449
+ if (eqIdx < 0) skipNext = true;
1450
+ continue;
1451
+ }
1452
+ }
1453
+
1454
+ if (isAssignment(part)) {
1455
+ assignments.push(part);
1456
+ continue;
1457
+ }
1458
+ commandStartIdx = i;
1459
+ break;
1460
+ }
1461
+
1462
+ if (commandStartIdx < 0) return undefined;
1463
+ // Keep environment assignments and argv boundaries for recursive inspection.
1464
+ return [...assignments, ...parts.slice(commandStartIdx)];
1465
+ }
1466
+
1467
+ function quoteShellArgument(argument: string): string {
1468
+ return `'${argument.replaceAll("'", "'\\''")}'`;
1469
+ }
1470
+
1471
+ interface ShellPayload {
1472
+ content: string;
1473
+ segment: number;
1474
+ cwd?: string;
1475
+ cwdUnknown?: boolean;
1476
+ }
1477
+
1478
+ function collectShellPayloadsWithContext(
1479
+ tokens: Token[],
1480
+ cwd?: string,
1481
+ cwdUnknown = false,
1482
+ env: Record<string, string | undefined> = process.env,
1483
+ ): ShellPayload[] {
1484
+ const payloads: ShellPayload[] = [];
1485
+ const addPayload = (content: string, segment: number, sourceIndex: number, sourceIsEnv = false) => {
1486
+ if (cwd === undefined) {
1487
+ payloads.push({ content, segment });
1488
+ return;
1489
+ }
1490
+ const commandCwd = commandWorkingDirectory(tokens, sourceIndex, cwd, env);
1491
+ const payloadCwd =
1492
+ sourceIsEnv && commandCwd !== undefined
1493
+ ? envWrapperWorkingDirectory(tokens, sourceIndex, commandCwd, env)
1494
+ : commandCwd;
1495
+ payloads.push({
1496
+ content,
1497
+ segment,
1498
+ cwd: payloadCwd ?? cwd,
1499
+ cwdUnknown: cwdUnknown || payloadCwd === undefined,
1500
+ });
1501
+ };
1502
+ const addEnvSplitPayload = (
1503
+ command: string[] | undefined,
1504
+ segment: number,
1505
+ sourceIndex: number,
1506
+ tailStart: number,
1507
+ ) => {
1508
+ if (!command) return;
1509
+ const argv = [...command];
1510
+ for (let cursor = tailStart; cursor < tokens.length && tokens[cursor].segment === segment; cursor++) {
1511
+ if (!tokens[cursor].isRedirectionTarget) argv.push(tokens[cursor].text);
1512
+ }
1513
+ addPayload(argv.map(quoteShellArgument).join(" "), segment, sourceIndex, true);
1514
+ };
1515
+ // Options that take an argument for bash/sh/similar shells
1516
+ const optionsWithArg = new Set(["-O", "-o", "+O", "+o"]);
1517
+
1518
+ for (let index = 0; index < tokens.length; index++) {
1519
+ const token = tokens[index];
1520
+ if (!token.commandStart || token.executionSuppressed) continue;
1521
+ if (isCommandLookup(tokens, index)) continue;
1522
+ if (
1523
+ commandName(token.text) === "eval" &&
1524
+ (token.wrappedBy === undefined || token.wrappedBy === "builtin" || token.wrappedBy === "command")
1525
+ ) {
1526
+ const arguments_: string[] = [];
1527
+ for (let cursor = index + 1; cursor < tokens.length && tokens[cursor].segment === token.segment; cursor++) {
1528
+ if (!tokens[cursor].isRedirectionTarget) arguments_.push(tokens[cursor].text);
1529
+ }
1530
+ if (arguments_.length > 0) addPayload(arguments_.join(" "), token.segment, index);
1531
+ continue;
1532
+ }
1533
+
1534
+ // Check if this is a shell runner or busybox
1535
+ let shellIndex = index;
1536
+ if (commandName(token.text) === "busybox") {
1537
+ // For busybox, the next non-option token is the applet name
1538
+ // Check if it's a shell applet
1539
+ for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
1540
+ const arg = tokens[cursor].text;
1541
+ if (arg.startsWith("-")) continue; // Skip busybox options
1542
+ // This is the applet name
1543
+ if (SHELL_RUNNERS.has(arg)) {
1544
+ // The applet is a shell, so we should scan from here
1545
+ shellIndex = cursor;
1546
+ }
1547
+ break;
1548
+ }
1549
+ }
1550
+
1551
+ if (!SHELL_RUNNERS.has(commandName(tokens[shellIndex].text))) continue;
1552
+
1553
+ // Scan for -c options in the shell invocation.
1554
+ // Look for patterns like:
1555
+ // - bash -c 'payload'
1556
+ // - bash -ce 'payload' (bundled option)
1557
+ // - bash -c -e 'payload' (separate options)
1558
+ // - bash -O extglob -c 'payload' (shopt with operand)
1559
+ // - bash -o pipefail -c 'payload' (shopt with operand)
1560
+ // - bash --rcfile FILE -c 'payload' (long option with argument)
1561
+ // - bash --norc -c 'payload' (long option without argument)
1562
+ // POSIX shells stop option parsing at the first non-option argument,
1563
+ // so after we see a non-option word (script name), any flags are arguments to that script.
1564
+
1565
+ let skipNextArg = false;
1566
+ let skipNextOption: string | undefined;
1567
+ let noExecMode = false;
1568
+
1569
+ for (let cursor = shellIndex + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
1570
+ const word = tokens[cursor].text;
1571
+
1572
+ // If the previous option takes an argument, skip this word
1573
+ if (skipNextArg) {
1574
+ skipNextArg = false;
1575
+ if (skipNextOption === "-o" && word === "noexec") noExecMode = true;
1576
+ else if (skipNextOption === "+o" && word === "noexec") noExecMode = false;
1577
+ skipNextOption = undefined;
1578
+ continue;
1579
+ }
1580
+ if (word === "--") break;
1581
+ if (word === "--dump-strings") {
1582
+ noExecMode = true;
1583
+ continue;
1584
+ }
1585
+ if (word === "+O" || word === "+o" || word === "-O" || word === "-o") {
1586
+ skipNextArg = true;
1587
+ skipNextOption = word;
1588
+ continue;
1589
+ }
1590
+ if (word.startsWith("+O") || word.startsWith("+o")) {
1591
+ if (word.startsWith("+o") && word.slice(2) === "noexec") noExecMode = false;
1592
+ continue;
1593
+ }
1594
+ if (word.startsWith("-o") && word.length > 2 && word !== "-oc") {
1595
+ if (word.slice(2) === "noexec") noExecMode = true;
1596
+ continue;
1597
+ }
1598
+ if (isPositiveShellOption(word)) {
1599
+ if (shellOptionEnablesExecution(word)) noExecMode = false;
1600
+ continue;
1601
+ }
1602
+ if (word.startsWith("+") && !word.startsWith("++")) {
1603
+ const flags = word.slice(1);
1604
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
1605
+ if (operandOptionIndex >= 0) {
1606
+ const option = flags[operandOptionIndex] === "o" ? "+o" : "+O";
1607
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
1608
+ if (attachedOperand.length === 0) {
1609
+ skipNextArg = true;
1610
+ skipNextOption = option;
1611
+ } else if (option === "+o" && attachedOperand === "noexec") {
1612
+ noExecMode = false;
1613
+ }
1614
+ continue;
1615
+ }
1616
+ }
1617
+ if (shellOptionDisablesExecution(word)) noExecMode = true;
1618
+
1619
+ // Once we see a non-option word (not starting with -), that's the script name.
1620
+ // Any further flags are arguments to that script, not shell options.
1621
+ if (!word.startsWith("-")) {
1622
+ break; // Stop scanning for -c after the first operand
1623
+ }
1624
+
1625
+ // Check if this is a long option
1626
+ if (word.startsWith("--")) {
1627
+ if (word === "--help" || word === "--version") break;
1628
+ // Handle long options with = (e.g., --rcfile=FILE)
1629
+ if (word.includes("=")) {
1630
+ skipNextArg = false;
1631
+ continue;
1632
+ }
1633
+ // Handle long options that take an argument
1634
+ if (SHELL_LONG_OPTIONS_WITH_ARG.has(word)) {
1635
+ skipNextArg = true;
1636
+ continue;
1637
+ }
1638
+ // All other long options are flag-only (e.g., --norc, --noprofile)
1639
+ continue;
1640
+ }
1641
+
1642
+ // Check if this option takes an argument (e.g., -O, -o for bash)
1643
+ if (optionsWithArg.has(word)) {
1644
+ skipNextArg = true;
1645
+ continue;
1646
+ }
1647
+
1648
+ // In a mixed short-option bundle, -O and -o consume the remainder or the
1649
+ // following argv element; do not mistake that operand for a script path.
1650
+ if (word.startsWith("-") && !word.startsWith("--") && !word.startsWith("-O") && !word.startsWith("-o")) {
1651
+ const flags = word.slice(1);
1652
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
1653
+ const commandOptionIndex = flags.indexOf("c");
1654
+ if (operandOptionIndex >= 0 && (commandOptionIndex < 0 || operandOptionIndex < commandOptionIndex)) {
1655
+ const option = flags[operandOptionIndex] === "o" ? "-o" : "-O";
1656
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
1657
+ if (attachedOperand.length === 0) {
1658
+ skipNextArg = true;
1659
+ skipNextOption = option;
1660
+ } else if (option === "-o" && attachedOperand === "noexec") {
1661
+ noExecMode = true;
1662
+ }
1663
+ continue;
1664
+ }
1665
+ }
1666
+ // Handle options with bundled operands (e.g., -Oextglob, -opipefail)
1667
+ // But also check if -c is in the bundle (e.g., -Oc means -O c where c is the shopt option)
1668
+ if ((word.startsWith("-O") && word.length > 2) || (word.startsWith("-o") && word.length > 2)) {
1669
+ // The operand is bundled with the option
1670
+ // Check if -c is also in the bundle after the operand
1671
+ const bundledChars = word.slice(2); // e.g., "extglob" or "c"
1672
+ if (bundledChars.includes("c")) {
1673
+ // -c is in the bundle, so we need to find the command string
1674
+ // The command comes after this option word
1675
+ for (
1676
+ let payloadCursor = cursor + 1;
1677
+ payloadCursor < tokens.length && !tokens[payloadCursor].commandStart;
1678
+ payloadCursor++
1679
+ ) {
1680
+ const payloadWord = tokens[payloadCursor].text;
1681
+ // Skip the first non-option word (it's the operand for -O)
1682
+ if (!payloadWord.startsWith("-")) {
1683
+ // This is the operand for -O; the next non-option is the -c operand
1684
+ for (
1685
+ let commandCursor = payloadCursor + 1;
1686
+ commandCursor < tokens.length && !tokens[commandCursor].commandStart;
1687
+ commandCursor++
1688
+ ) {
1689
+ const commandWord = tokens[commandCursor].text;
1690
+ if (!commandWord.startsWith("-") || tokens[commandCursor].quoted) {
1691
+ addPayload(commandWord, token.segment, index);
1692
+ break;
1693
+ }
1694
+ }
1695
+ break;
1696
+ }
1697
+ }
1698
+ }
1699
+ continue;
1700
+ }
1701
+
1702
+ // Check for exact -c option or bundled forms like -ce, -ec
1703
+ const isStandaloneC = word === "-c";
1704
+ const isBundledC = word.startsWith("-") && !word.startsWith("--") && word.length > 2 && word.includes("c");
1705
+ if (isStandaloneC || isBundledC) {
1706
+ // Found a -c option (standalone or bundled)
1707
+ // Options such as -O and -o still take operands when bundled after -c.
1708
+ const commandOptionIndex = word.indexOf("c");
1709
+ const followingOperandCount = [...word.slice(commandOptionIndex + 1)].filter(
1710
+ option => option === "O" || option === "o",
1711
+ ).length;
1712
+ let optionsTerminated = false;
1713
+ let payloadNoExec = noExecMode;
1714
+ for (
1715
+ let payloadCursor = cursor + 1 + followingOperandCount;
1716
+ payloadCursor < tokens.length && !tokens[payloadCursor].commandStart;
1717
+ payloadCursor++
1718
+ ) {
1719
+ const payloadToken = tokens[payloadCursor];
1720
+ const payloadWord = payloadToken.text;
1721
+
1722
+ // Bash still accepts invocation options after -c. A bare -- ends
1723
+ // option parsing, so the following argument is the command string
1724
+ // even when it starts with a dash.
1725
+ if (!optionsTerminated && payloadWord === "--") {
1726
+ optionsTerminated = true;
1727
+ continue;
1728
+ }
1729
+ if (!optionsTerminated && (payloadWord === "+O" || payloadWord === "+o")) {
1730
+ if (payloadWord === "+o" && tokens[payloadCursor + 1]?.text === "noexec") payloadNoExec = false;
1731
+ payloadCursor++;
1732
+ continue;
1733
+ }
1734
+ if (!optionsTerminated && (payloadWord.startsWith("+O") || payloadWord.startsWith("+o"))) {
1735
+ if (payloadWord.startsWith("+o") && payloadWord.slice(2) === "noexec") payloadNoExec = false;
1736
+ continue;
1737
+ }
1738
+ if (!optionsTerminated && isPositiveShellOption(payloadWord)) {
1739
+ if (shellOptionEnablesExecution(payloadWord)) payloadNoExec = false;
1740
+ continue;
1741
+ }
1742
+ if (!optionsTerminated && payloadWord.startsWith("+") && !payloadWord.startsWith("++")) {
1743
+ const flags = payloadWord.slice(1);
1744
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
1745
+ if (operandOptionIndex >= 0) {
1746
+ const option = flags[operandOptionIndex] === "o" ? "+o" : "+O";
1747
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
1748
+ if (attachedOperand.length === 0) {
1749
+ if (option === "+o" && tokens[payloadCursor + 1]?.text === "noexec") payloadNoExec = false;
1750
+ payloadCursor++;
1751
+ } else if (option === "+o" && attachedOperand === "noexec") {
1752
+ payloadNoExec = false;
1753
+ }
1754
+ continue;
1755
+ }
1756
+ }
1757
+ if (!optionsTerminated && shellOptionDisablesExecution(payloadWord)) payloadNoExec = true;
1758
+
1759
+ // Skip over any remaining shell options or their arguments. Quotes
1760
+ // don't change how Bash interprets an argv value beginning with '-'.
1761
+ if (!optionsTerminated && payloadWord.startsWith("-")) {
1762
+ // A quoted argv value that starts with a dash but contains shell
1763
+ // syntax may be treated as command text by another supported shell
1764
+ // version. Inspect it conservatively instead of assuming it is an
1765
+ // invocation option.
1766
+ if (payloadToken.quoted && /[\s;&|()<>$`]/.test(payloadWord)) {
1767
+ if (!payloadNoExec) addPayload(payloadWord, token.segment, index);
1768
+ break;
1769
+ }
1770
+ if (payloadWord.startsWith("-o") && payloadWord.length > 2) {
1771
+ if (payloadWord.slice(2) === "noexec") payloadNoExec = true;
1772
+ continue;
1773
+ }
1774
+ const flags = payloadWord.slice(1);
1775
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
1776
+ if (operandOptionIndex >= 0) {
1777
+ const option = flags[operandOptionIndex] === "o" ? "-o" : "-O";
1778
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
1779
+ if (attachedOperand.length === 0) {
1780
+ if (option === "-o" && tokens[payloadCursor + 1]?.text === "noexec") payloadNoExec = true;
1781
+ payloadCursor++;
1782
+ } else if (option === "-o" && attachedOperand === "noexec") {
1783
+ payloadNoExec = true;
1784
+ }
1785
+ continue;
1786
+ }
1787
+ // Check if this option takes an argument
1788
+ if (optionsWithArg.has(payloadWord)) {
1789
+ if (payloadWord === "-o" && tokens[payloadCursor + 1]?.text === "noexec") payloadNoExec = true;
1790
+ payloadCursor++; // Skip the argument
1791
+ }
1792
+ // Handle options with bundled operands (e.g., -Oextglob)
1793
+ if (
1794
+ (payloadWord.startsWith("-O") && payloadWord.length > 2) ||
1795
+ (payloadWord.startsWith("-o") && payloadWord.length > 2)
1796
+ ) {
1797
+ // The operand is bundled with the option, no need to skip
1798
+ }
1799
+ continue;
1800
+ }
1801
+
1802
+ // Found the first non-option word after -c; this is the actual command string
1803
+ // Add it regardless of quoted status, because bash will execute it as the command
1804
+ if (!payloadNoExec) addPayload(payloadWord, token.segment, index);
1805
+ break; // Only take the first payload for this -c
1806
+ }
1807
+ break; // -c stops option parsing
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // Handle env -S/--split-string payloads
1813
+ // env -S 'payload' splits the string and executes the result
1814
+ for (let index = 0; index < tokens.length; index++) {
1815
+ const token = tokens[index];
1816
+ if (
1817
+ !token.commandStart ||
1818
+ isCommandLookup(tokens, index) ||
1819
+ (token.text !== "env" && !token.text.endsWith("/env"))
1820
+ )
1821
+ continue;
1822
+
1823
+ // Options for env that consume the next argument
1824
+ const envOptionsWithArg = new Set(["-u", "-C", "-S", "-a", "-P"]);
1825
+ let skipNextArg = false;
1826
+
1827
+ // Look for -S or --split-string option
1828
+ let inEnv = true;
1829
+ for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart && inEnv; cursor++) {
1830
+ const arg = tokens[cursor].text;
1831
+
1832
+ // If the previous option takes an argument, skip this token
1833
+ if (skipNextArg) {
1834
+ skipNextArg = false;
1835
+ continue;
1836
+ }
1837
+
1838
+ // Stop at the first non-option, non-assignment
1839
+ if (!arg.startsWith("-") && !isAssignment(arg)) {
1840
+ inEnv = false;
1841
+ break;
1842
+ }
1843
+
1844
+ // Check for -S option (standalone)
1845
+ if (arg === "-S") {
1846
+ // The next argument is the split-string payload
1847
+ if (cursor + 1 < tokens.length && !tokens[cursor + 1].commandStart) {
1848
+ const splitString = tokens[cursor + 1].text;
1849
+ // env -S splits the string and processes it through env's own option parsing
1850
+ // Extract and push the actual command that env will execute
1851
+ addEnvSplitPayload(reconstructCommandFromEnvSplit(splitString), token.segment, index, cursor + 2);
1852
+ cursor++; // Skip the payload we just processed
1853
+ inEnv = false;
1854
+ }
1855
+ }
1856
+ // GNU env also accepts the split string attached to -S.
1857
+ else if (arg.startsWith("-S") && arg.length > 2) {
1858
+ addEnvSplitPayload(reconstructCommandFromEnvSplit(arg.slice(2)), token.segment, index, cursor + 1);
1859
+ inEnv = false;
1860
+ }
1861
+ // Check for --split-string option (with or without =)
1862
+ else if (arg.startsWith("--split-string")) {
1863
+ if (arg.includes("=")) {
1864
+ // Handle --split-string=value format
1865
+ // Use substring to get everything after the first =, not just up to the second =
1866
+ const eqIndex = arg.indexOf("=");
1867
+ const value = arg.substring(eqIndex + 1);
1868
+ addEnvSplitPayload(reconstructCommandFromEnvSplit(value), token.segment, index, cursor + 1);
1869
+ inEnv = false;
1870
+ } else if (arg === "--split-string") {
1871
+ // Handle --split-string value format (separate argument)
1872
+ if (cursor + 1 < tokens.length && !tokens[cursor + 1].commandStart) {
1873
+ const splitString = tokens[cursor + 1].text;
1874
+ addEnvSplitPayload(reconstructCommandFromEnvSplit(splitString), token.segment, index, cursor + 2);
1875
+ cursor++; // Skip the payload we just processed
1876
+ inEnv = false;
1877
+ }
1878
+ }
1879
+ }
1880
+ // Track options that take arguments so we skip their operands
1881
+ else if (envOptionsWithArg.has(arg)) {
1882
+ skipNextArg = true;
1883
+ }
1884
+ // Check for long options with = format (--option=value)
1885
+ else if (arg.startsWith("--") && arg.includes("=")) {
1886
+ const optionName = matchingWrapperLongOption("env", arg.slice(0, arg.indexOf("=")));
1887
+ if (optionName === "--split-string") {
1888
+ const splitString = arg.slice(arg.indexOf("=") + 1);
1889
+ addEnvSplitPayload(reconstructCommandFromEnvSplit(splitString), token.segment, index, cursor + 1);
1890
+ inEnv = false;
1891
+ }
1892
+ }
1893
+ // Check for long options without = that might take arguments
1894
+ else if (arg.startsWith("--")) {
1895
+ const optionName = matchingWrapperLongOption("env", arg);
1896
+ if (optionName === "--split-string" && cursor + 1 < tokens.length && !tokens[cursor + 1].commandStart) {
1897
+ addEnvSplitPayload(
1898
+ reconstructCommandFromEnvSplit(tokens[cursor + 1].text),
1899
+ token.segment,
1900
+ index,
1901
+ cursor + 2,
1902
+ );
1903
+ cursor++;
1904
+ inEnv = false;
1905
+ } else if (optionName !== undefined && WRAPPER_LONG_OPTIONS.env[optionName]) {
1906
+ skipNextArg = true;
1907
+ }
1908
+ } else if (arg.startsWith("-") && arg.length > 2) {
1909
+ for (let optionIndex = 1; optionIndex < arg.length; optionIndex++) {
1910
+ const option = `-${arg[optionIndex]}`;
1911
+ if (!envOptionsWithArg.has(option)) continue;
1912
+
1913
+ if (option === "-S") {
1914
+ const attachedSplitString = arg.slice(optionIndex + 1);
1915
+ const splitString =
1916
+ attachedSplitString || (optionIndex === arg.length - 1 ? tokens[cursor + 1]?.text : undefined);
1917
+ if (splitString !== undefined) {
1918
+ const splitTailStart =
1919
+ attachedSplitString.length === 0 && optionIndex === arg.length - 1 ? cursor + 2 : cursor + 1;
1920
+ addEnvSplitPayload(
1921
+ reconstructCommandFromEnvSplit(splitString),
1922
+ token.segment,
1923
+ index,
1924
+ splitTailStart,
1925
+ );
1926
+ if (attachedSplitString.length === 0 && optionIndex === arg.length - 1) cursor++;
1927
+ inEnv = false;
1928
+ }
1929
+
1930
+ break;
1931
+ }
1932
+
1933
+ if (optionIndex === arg.length - 1) skipNextArg = true;
1934
+ break;
1935
+ }
1936
+ }
1937
+ }
1938
+ }
1939
+
1940
+ return payloads;
1941
+ }
1942
+
1943
+ function collectShellPayloads(tokens: Token[]): string[] {
1944
+ return collectShellPayloadsWithContext(tokens).map(payload => payload.content);
1945
+ }
1946
+
1947
+ function shellReadsStdin(
1948
+ arguments_: Token[],
1949
+ stdinRedirectSource?: "here-document" | "here-string" | "other",
1950
+ ): boolean {
1951
+ if (stdinRedirectSource === "other") return false;
1952
+ const optionsWithArgument = new Set(["-O", "-o"]);
1953
+ let skipNextArgument = false;
1954
+ let skipNextOption: string | undefined;
1955
+ let optionsTerminated = false;
1956
+ let readsStdin = false;
1957
+ let noExecMode = false;
1958
+
1959
+ for (const token of arguments_) {
1960
+ const argument = token.text;
1961
+ if (skipNextArgument) {
1962
+ skipNextArgument = false;
1963
+ if (skipNextOption === "-o" && argument === "noexec") noExecMode = true;
1964
+ else if (skipNextOption === "+o" && argument === "noexec") noExecMode = false;
1965
+ skipNextOption = undefined;
1966
+ continue;
1967
+ }
1968
+ if (optionsTerminated)
1969
+ return !noExecMode && (readsStdin || argument === "-" || SHELL_STDIN_SCRIPT_PATHS.has(argument));
1970
+ if (argument === "--dump-strings") {
1971
+ noExecMode = true;
1972
+ continue;
1973
+ }
1974
+ if (argument === "--") {
1975
+ optionsTerminated = true;
1976
+ continue;
1977
+ }
1978
+ if (argument === "-" || argument === "-s" || SHELL_STDIN_SCRIPT_PATHS.has(argument)) {
1979
+ readsStdin = true;
1980
+ continue;
1981
+ }
1982
+ if (
1983
+ argument === "-c" ||
1984
+ (argument.startsWith("-") &&
1985
+ !argument.startsWith("--") &&
1986
+ argument.length > 2 &&
1987
+ argument.slice(1).includes("c"))
1988
+ ) {
1989
+ return false;
1990
+ }
1991
+ if (argument === "+O" || argument === "+o") {
1992
+ skipNextArgument = true;
1993
+ skipNextOption = argument;
1994
+ continue;
1995
+ }
1996
+ if (argument.startsWith("+O") || argument.startsWith("+o")) {
1997
+ if (argument.startsWith("+o") && argument.slice(2) === "noexec") noExecMode = false;
1998
+ continue;
1999
+ }
2000
+ if (isPositiveShellOption(argument)) {
2001
+ if (shellOptionEnablesExecution(argument)) noExecMode = false;
2002
+ continue;
2003
+ }
2004
+ if (argument.startsWith("+") && !argument.startsWith("++")) {
2005
+ const flags = argument.slice(1);
2006
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
2007
+ if (operandOptionIndex >= 0) {
2008
+ const option = flags[operandOptionIndex] === "o" ? "+o" : "+O";
2009
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
2010
+ if (attachedOperand.length === 0) {
2011
+ skipNextArgument = true;
2012
+ skipNextOption = option;
2013
+ } else if (option === "+o" && attachedOperand === "noexec") {
2014
+ noExecMode = false;
2015
+ }
2016
+ continue;
2017
+ }
2018
+ }
2019
+ if (shellOptionDisablesExecution(argument)) {
2020
+ noExecMode = true;
2021
+ continue;
2022
+ }
2023
+ if (argument.startsWith("--")) {
2024
+ if (!argument.includes("=") && SHELL_LONG_OPTIONS_WITH_ARG.has(argument)) skipNextArgument = true;
2025
+ continue;
2026
+ }
2027
+ if (argument.startsWith("-")) {
2028
+ if (optionsWithArgument.has(argument)) {
2029
+ skipNextArgument = true;
2030
+ skipNextOption = argument;
2031
+ }
2032
+ if (argument.startsWith("-o") && argument.length > 2) {
2033
+ if (argument.slice(2) === "noexec") noExecMode = true;
2034
+ continue;
2035
+ }
2036
+ const flags = argument.slice(1);
2037
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
2038
+ if (operandOptionIndex >= 0) {
2039
+ const option = flags[operandOptionIndex] === "o" ? "-o" : "-O";
2040
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
2041
+ if (attachedOperand.length === 0) {
2042
+ skipNextArgument = true;
2043
+ skipNextOption = option;
2044
+ } else if (option === "-o" && attachedOperand === "noexec") {
2045
+ noExecMode = true;
2046
+ }
2047
+ }
2048
+ continue;
2049
+ }
2050
+ return !noExecMode && readsStdin;
2051
+ }
2052
+ return !noExecMode;
2053
+ }
2054
+
2055
+ function shellRunnerIndex(tokens: Token[], commandIndex: number, segment: number): number | undefined {
2056
+ const command = tokens[commandIndex];
2057
+ if (commandName(command.text) === "busybox") {
2058
+ for (let cursor = commandIndex + 1; cursor < tokens.length; cursor++) {
2059
+ const argument = tokens[cursor];
2060
+ if (argument.segment !== segment || argument.commandStart) break;
2061
+ if (argument.isRedirectionTarget || argument.text.startsWith("-")) continue;
2062
+ const applet = commandName(argument.text);
2063
+ return applet !== "busybox" && SHELL_RUNNERS.has(applet) ? cursor : undefined;
2064
+ }
2065
+ return undefined;
2066
+ }
2067
+ return SHELL_RUNNERS.has(commandName(command.text)) ? commandIndex : undefined;
2068
+ }
2069
+
2070
+ function shellArguments(tokens: Token[], shellIndex: number, segment: number): Token[] {
2071
+ const args: Token[] = [];
2072
+ for (let cursor = shellIndex + 1; cursor < tokens.length; cursor++) {
2073
+ const argument = tokens[cursor];
2074
+ if (argument.segment !== segment || argument.commandStart) break;
2075
+ if (!argument.isRedirectionTarget) args.push(argument);
2076
+ }
2077
+ return args;
2078
+ }
2079
+
2080
+ function xargsUsesArgumentFile(tokens: Token[], xargsIndex: number, commandIndex: number, segment: number): boolean {
2081
+ const wrapper = { name: "xargs", spec: COMMAND_WRAPPERS_WITH_ARGS.xargs };
2082
+ let skipNextArgument = false;
2083
+
2084
+ for (let index = xargsIndex + 1; index < commandIndex; index++) {
2085
+ const argument = tokens[index];
2086
+ if (argument.segment !== segment) break;
2087
+ if (skipNextArgument) {
2088
+ skipNextArgument = false;
2089
+ continue;
2090
+ }
2091
+ if (argument.text === "--") break;
2092
+ if (argument.text === "--arg-file" || argument.text.startsWith("--arg-file=")) return true;
2093
+ if (!argument.text.startsWith("-") || argument.text === "-") continue;
2094
+ if (argument.text.startsWith("--")) {
2095
+ if (wrapperOptionTakesNextArgument(wrapper, argument.text)) skipNextArgument = true;
2096
+ continue;
2097
+ }
2098
+ for (let optionIndex = 1; optionIndex < argument.text.length; optionIndex++) {
2099
+ const shortOption = `-${argument.text[optionIndex]}`;
2100
+ if (shortOption === "-a") return true;
2101
+ if (wrapper.spec.optionsWithArg.has(shortOption)) {
2102
+ if (optionIndex === argument.text.length - 1) skipNextArgument = true;
2103
+ break;
2104
+ }
2105
+ }
2106
+ }
2107
+ return false;
2108
+ }
2109
+
2110
+ function xargsConsumesStdin(tokens: Token[], commandIndex: number, segment: number): boolean {
2111
+ for (let index = commandIndex - 1; index >= 0; index--) {
2112
+ const token = tokens[index];
2113
+ if (token.segment !== segment) break;
2114
+ if (!token.commandStart) continue;
2115
+ const name = commandName(token.text);
2116
+ if (name === "xargs") return !xargsUsesArgumentFile(tokens, index, commandIndex, segment);
2117
+ if (!COMMAND_WRAPPERS.has(name) && !COMMAND_WRAPPERS_WITH_ARGS[name]) break;
2118
+ }
2119
+ return false;
2120
+ }
2121
+
2122
+ function sourceBuiltinReadsStdin(tokens: Token[], commandIndex: number, segment: number): boolean {
2123
+ const command = tokens[commandIndex].text;
2124
+ if (command !== "." && command !== "source") return false;
2125
+
2126
+ for (let index = commandIndex + 1; index < tokens.length; index++) {
2127
+ const argument = tokens[index];
2128
+ if (argument.segment !== segment || argument.commandStart) break;
2129
+ if (argument.isRedirectionTarget || argument.text === "--") continue;
2130
+ return SHELL_STDIN_SCRIPT_PATHS.has(argument.text);
2131
+ }
2132
+ return false;
2133
+ }
2134
+
2135
+ function shellStdinConsumerIndex(tokens: Token[], segment: number, hereDocumentOrder?: number): number | undefined {
2136
+ const segmentTokens = tokens.filter(token => token.segment === segment);
2137
+ for (let index = 0; index < segmentTokens.length; index++) {
2138
+ const token = segmentTokens[index];
2139
+ if (!token.commandStart || token.executionSuppressed || isCommandLookup(segmentTokens, index)) {
2140
+ continue;
2141
+ }
2142
+ const shellIndex = shellRunnerIndex(segmentTokens, index, segment);
2143
+ const readsStdin =
2144
+ shellIndex !== undefined &&
2145
+ shellReadsStdin(shellArguments(segmentTokens, shellIndex, segment), token.stdinRedirectSource);
2146
+ const sourcesStdin =
2147
+ token.stdinRedirectSource !== "other" && sourceBuiltinReadsStdin(segmentTokens, index, segment);
2148
+ const readsRedirectedScript =
2149
+ (token.stdinRedirectSource !== "here-document" && token.stdinRedirectSource !== "here-string") ||
2150
+ (token.stdinRedirectOrder === hereDocumentOrder && hereDocumentOrder !== undefined);
2151
+ if ((readsStdin || sourcesStdin) && readsRedirectedScript && !xargsConsumesStdin(segmentTokens, index, segment)) {
2152
+ return tokens.indexOf(token);
2153
+ }
2154
+ }
2155
+ return undefined;
2156
+ }
2157
+
2158
+ function hasShellStdinConsumer(tokens: Token[], segment: number, depth = 0, hereDocumentOrder?: number): boolean {
2159
+ if (shellStdinConsumerIndex(tokens, segment, hereDocumentOrder) !== undefined) return true;
2160
+ const segmentTokens = tokens.filter(token => token.segment === segment);
2161
+ if (depth >= MAX_INDIRECTION_DEPTH) return false;
2162
+ for (let index = 0; index < segmentTokens.length; index++) {
2163
+ const token = segmentTokens[index];
2164
+ if (
2165
+ !token.commandStart ||
2166
+ token.executionSuppressed ||
2167
+ shellRunnerIndex(segmentTokens, index, segment) === undefined
2168
+ )
2169
+ continue;
2170
+ if (
2171
+ xargsConsumesStdin(segmentTokens, index, segment) ||
2172
+ token.stdinRedirectSource === "other" ||
2173
+ ((token.stdinRedirectSource === "here-document" || token.stdinRedirectSource === "here-string") &&
2174
+ (token.stdinRedirectOrder !== hereDocumentOrder || hereDocumentOrder === undefined))
2175
+ ) {
2176
+ return false;
2177
+ }
2178
+ }
2179
+
2180
+ for (const payload of collectShellPayloads(segmentTokens)) {
2181
+ const nested = tokenize(payload);
2182
+ const pipelinesWithOwnStdin = new Set(
2183
+ nested.hereDocuments
2184
+ .filter(hereDocument => {
2185
+ if (!hereDocument.stdin) return false;
2186
+ const redirectOrder = nested.tokens.find(
2187
+ token => token.segment === hereDocument.segment && token.commandStart,
2188
+ )?.stdinRedirectOrder;
2189
+ return redirectOrder === hereDocument.redirectOrder;
2190
+ })
2191
+ .map(hereDocument => hereDocument.pipeline),
2192
+ );
2193
+ for (const nestedSegment of new Set(nested.tokens.map(token => token.segment))) {
2194
+ const nestedPipeline = nested.tokens.find(token => token.segment === nestedSegment)?.pipeline;
2195
+ if (nestedPipeline !== undefined && pipelinesWithOwnStdin.has(nestedPipeline)) continue;
2196
+ if (hasShellStdinConsumer(nested.tokens, nestedSegment, depth + 1)) return true;
2197
+ }
2198
+ }
2199
+ return false;
2200
+ }
2201
+
2202
+ function hereDocumentDelimiterAt(
2203
+ source: string,
2204
+ operatorIndex: number,
2205
+ ): { delimiter: string; stripTabs: boolean; end: number } | undefined {
2206
+ if (source[operatorIndex] !== "<" || source[operatorIndex + 1] !== "<" || source[operatorIndex + 2] === "<") {
2207
+ return undefined;
2208
+ }
2209
+ let cursor = operatorIndex + 2;
2210
+ let stripTabs = false;
2211
+ if (source[cursor] === "-") {
2212
+ stripTabs = true;
2213
+ cursor++;
2214
+ }
2215
+ while (source[cursor] === " " || source[cursor] === "\t") cursor++;
2216
+ let delimiter = "";
2217
+ let argumentStarted = false;
2218
+ while (cursor < source.length) {
2219
+ const character = source[cursor];
2220
+ if (character === "'") {
2221
+ argumentStarted = true;
2222
+ cursor++;
2223
+ while (cursor < source.length && source[cursor] !== "'") delimiter += source[cursor++];
2224
+ if (source[cursor] !== "'") return undefined;
2225
+ cursor++;
2226
+ continue;
2227
+ }
2228
+ if (character === '"') {
2229
+ argumentStarted = true;
2230
+ cursor++;
2231
+ while (cursor < source.length && source[cursor] !== '"') {
2232
+ if (source[cursor] === "\\" && cursor + 1 < source.length) cursor++;
2233
+ delimiter += source[cursor++];
2234
+ }
2235
+ if (source[cursor] !== '"') return undefined;
2236
+ cursor++;
2237
+ continue;
2238
+ }
2239
+ if (character === "\\" && cursor + 1 < source.length) {
2240
+ argumentStarted = true;
2241
+ delimiter += source[cursor + 1];
2242
+ cursor += 2;
2243
+ continue;
2244
+ }
2245
+ if (/[\s;|&()<>]/.test(character)) break;
2246
+ argumentStarted = true;
2247
+ delimiter += character;
2248
+ cursor++;
2249
+ }
2250
+ if (!argumentStarted) return undefined;
2251
+ return { delimiter, stripTabs, end: cursor - 1 };
2252
+ }
2253
+
2254
+ function skipHereDocumentBodies(
2255
+ source: string,
2256
+ newlineIndex: number,
2257
+ delimiters: Array<{ delimiter: string; stripTabs: boolean }>,
2258
+ ): number | undefined {
2259
+ let cursor = newlineIndex + 1;
2260
+ for (const document of delimiters) {
2261
+ let foundDelimiter = false;
2262
+ while (cursor < source.length) {
2263
+ const newline = source.indexOf("\n", cursor);
2264
+ const lineEnd = newline < 0 ? source.length : newline;
2265
+ let line = source.slice(cursor, lineEnd);
2266
+ if (document.stripTabs) line = line.replace(/^\t+/, "");
2267
+ if (line === document.delimiter) {
2268
+ if (newline < 0) return undefined;
2269
+ cursor = newline + 1;
2270
+ foundDelimiter = true;
2271
+ break;
2272
+ }
2273
+ if (newline < 0) return undefined;
2274
+ cursor = newline + 1;
2275
+ }
2276
+ if (!foundDelimiter) return undefined;
2277
+ }
2278
+ return cursor - 1;
2279
+ }
2280
+
2281
+ function commandSubstitutionEnd(source: string, start: number): number | undefined {
2282
+ let depth = 1;
2283
+ let singleQuoted = false;
2284
+ let doubleQuoted = false;
2285
+ let backtickQuoted = false;
2286
+ let caseDepth = 0;
2287
+ let expectingCaseIn = false;
2288
+ let inCasePattern = false;
2289
+ let shellWord = "";
2290
+ const hereDocumentDelimiters: Array<{ delimiter: string; stripTabs: boolean }> = [];
2291
+ const flushShellWord = () => {
2292
+ if (shellWord === "case") {
2293
+ caseDepth++;
2294
+ expectingCaseIn = true;
2295
+ } else if (shellWord === "in" && expectingCaseIn) {
2296
+ expectingCaseIn = false;
2297
+ inCasePattern = true;
2298
+ } else if (shellWord === "esac" && caseDepth > 0) {
2299
+ caseDepth--;
2300
+ inCasePattern = false;
2301
+ expectingCaseIn = false;
2302
+ }
2303
+ shellWord = "";
2304
+ };
2305
+
2306
+ for (let index = start + 2; index < source.length; index++) {
2307
+ const character = source[index];
2308
+ if (character === "\\" && !singleQuoted && index + 1 < source.length) {
2309
+ index++;
2310
+ continue;
2311
+ }
2312
+ if (singleQuoted) {
2313
+ if (character === "'") singleQuoted = false;
2314
+ continue;
2315
+ }
2316
+ if (backtickQuoted) {
2317
+ if (character === "`") backtickQuoted = false;
2318
+ continue;
2319
+ }
2320
+ if (doubleQuoted) {
2321
+ if (character === '"') {
2322
+ doubleQuoted = false;
2323
+ continue;
2324
+ }
2325
+ if (character === "`") backtickQuoted = true;
2326
+ if (character === "$" && source[index + 1] === "(") {
2327
+ const nestedEnd = commandSubstitutionEnd(source, index);
2328
+ if (nestedEnd !== undefined) index = nestedEnd;
2329
+ }
2330
+ continue;
2331
+ }
2332
+ if (/[A-Za-z0-9_]/.test(character)) {
2333
+ shellWord += character;
2334
+ continue;
2335
+ }
2336
+ flushShellWord();
2337
+ if (character === "<" && source[index + 1] === "<" && source[index + 2] !== "<") {
2338
+ const hereDocument = hereDocumentDelimiterAt(source, index);
2339
+ if (hereDocument) {
2340
+ hereDocumentDelimiters.push({ delimiter: hereDocument.delimiter, stripTabs: hereDocument.stripTabs });
2341
+ index = hereDocument.end;
2342
+ continue;
2343
+ }
2344
+ }
2345
+ if (character === "#" && (index === 0 || /[\s;|&()]/.test(source[index - 1]))) {
2346
+ while (index < source.length && source[index] !== "\n") index++;
2347
+ continue;
2348
+ }
2349
+ if (character === "'") {
2350
+ singleQuoted = true;
101
2351
  continue;
102
2352
  }
103
- if (ch === "\\" && index + 1 < command.length) {
104
- text += command[++index];
105
- quoted = true;
2353
+ if (character === '"') {
2354
+ doubleQuoted = true;
106
2355
  continue;
107
2356
  }
108
- if (/\s/.test(ch)) {
109
- flush();
110
- previous = ch;
2357
+ if (character === "`") {
2358
+ backtickQuoted = true;
111
2359
  continue;
112
2360
  }
113
- if (isSeparator(ch)) {
114
- flush();
115
- atCommandStart = true;
116
- wrapperPending = false;
117
- previous = ch;
2361
+ if (character === "$" && source[index + 1] === "(") {
2362
+ depth++;
2363
+ index++;
118
2364
  continue;
119
2365
  }
120
- // `sudo -u user tmux` and `env FOO=bar tmux` need the wrapped command
121
- // recognized. Options, option arguments, and environment assignments
122
- // stay outside command position.
123
- if (text.length === 0 && wrapperPending && previous && /\s/.test(previous)) {
124
- if (text.length === 0 && ch === "-") {
125
- // The option itself is not a command; its argument is also skipped.
126
- atCommandStart = false;
127
- }
2366
+ if (character === ";" && source[index + 1] === ";" && caseDepth > 0) {
2367
+ inCasePattern = true;
2368
+ index++;
2369
+ if (source[index + 1] === ";") index++;
2370
+ continue;
128
2371
  }
129
- if (text.length === 0 && wrapperPending && atCommandStart) {
130
- atCommandStart = true;
2372
+ if (character === "\n" && hereDocumentDelimiters.length > 0) {
2373
+ const afterHereDocuments = skipHereDocumentBodies(source, index, hereDocumentDelimiters);
2374
+ if (afterHereDocuments === undefined) return undefined;
2375
+ hereDocumentDelimiters.length = 0;
2376
+ index = afterHereDocuments;
2377
+ continue;
2378
+ }
2379
+ if (character === "(" && !inCasePattern) depth++;
2380
+ if (character === ")") {
2381
+ if (inCasePattern) {
2382
+ inCasePattern = false;
2383
+ continue;
2384
+ }
2385
+ depth--;
2386
+ if (depth === 0) return index;
131
2387
  }
132
- text += ch;
133
2388
  }
134
- flush();
135
- return tokens;
136
- }
137
-
138
- interface Invocation {
139
- verb: string;
140
- args: string[];
141
- socketArgs: string[];
142
- socket?: { flag: "-L" | "-S"; value: string };
2389
+ return undefined;
143
2390
  }
144
2391
 
145
- function parseInvocation(tokens: Token[]): Invocation | undefined {
146
- let index = 0;
147
- const socketArgs: string[] = [];
148
- let socket: Invocation["socket"];
149
- while (index < tokens.length && !tokens[index].commandStart) {
150
- const option = tokens[index].text;
151
- if (option === "--") {
2392
+ function backtickSubstitutionEnd(source: string, start: number): number | undefined {
2393
+ for (let index = start + 1; index < source.length; index++) {
2394
+ if (source[index] === "\\" && index + 1 < source.length) {
152
2395
  index++;
153
- break;
2396
+ continue;
154
2397
  }
155
- if (option === "-L" || option === "-S") {
156
- const value = tokens[index + 1];
157
- if (!value || value.commandStart) return undefined;
158
- socket = { flag: option, value: value.text };
159
- socketArgs.push(option, value.text);
160
- index += 2;
2398
+ if (source[index] === "`") return index;
2399
+ }
2400
+ return undefined;
2401
+ }
2402
+
2403
+ function joinUnquotedHereDocumentLines(body: string): string {
2404
+ let joined = "";
2405
+ for (let index = 0; index < body.length; index++) {
2406
+ if (body[index] !== "\\") {
2407
+ joined += body[index];
161
2408
  continue;
162
2409
  }
163
- if (option.startsWith("-L") || option.startsWith("-S")) {
164
- const flag = option.slice(0, 2) as "-L" | "-S";
165
- const value = option.slice(2);
166
- if (!value) return undefined;
167
- socket = { flag, value };
168
- socketArgs.push(flag, value);
169
- index++;
2410
+
2411
+ const start = index;
2412
+ while (body[index + 1] === "\\") index++;
2413
+ const slashCount = index - start + 1;
2414
+ if (body[index + 1] !== "\n") {
2415
+ joined += "\\".repeat(slashCount);
170
2416
  continue;
171
2417
  }
172
- if (option.startsWith("-")) {
2418
+
2419
+ if (slashCount % 2 === 1) {
2420
+ joined += "\\".repeat(slashCount - 1);
173
2421
  index++;
174
2422
  continue;
175
2423
  }
176
- break;
2424
+ joined += `${"\\".repeat(slashCount)}\n`;
2425
+ index++;
177
2426
  }
178
- const verb = tokens[index];
179
- if (!verb || verb.commandStart) return undefined;
180
- const args: string[] = [];
181
- for (index++; index < tokens.length && !tokens[index].commandStart; index++) args.push(tokens[index].text);
182
- return { verb: verb.text, args, socketArgs, socket };
2427
+ return joined;
183
2428
  }
184
2429
 
185
- function targetFromArgs(args: string[]): string | undefined {
186
- for (let index = 0; index < args.length; index++) {
187
- if (args[index] === "-t") return args[index + 1];
188
- if (args[index].startsWith("-t") && args[index].length > 2) return args[index].slice(2);
2430
+ function collectHereDocumentCommandSubstitutions(body: string): string[] {
2431
+ const substitutions = new Set<string>();
2432
+ const pendingSources = [body];
2433
+ while (pendingSources.length > 0) {
2434
+ const source = pendingSources.pop();
2435
+ if (source === undefined) continue;
2436
+ for (let index = 0; index < source.length; index++) {
2437
+ if (source[index] === "\\" && index + 1 < source.length) {
2438
+ const next = source[index + 1];
2439
+ if (next === "\\" || next === "$" || next === "`" || next === "\n") index++;
2440
+ continue;
2441
+ }
2442
+ if (source[index] === "$" && source[index + 1] === "(") {
2443
+ const end = commandSubstitutionEnd(source, index);
2444
+ if (end === undefined) continue;
2445
+ const command = source.slice(index + 2, end);
2446
+ if (command.length > 0 && !substitutions.has(command)) {
2447
+ substitutions.add(command);
2448
+ pendingSources.push(command);
2449
+ }
2450
+ index = end;
2451
+ continue;
2452
+ }
2453
+ if (source[index] === "`") {
2454
+ const end = backtickSubstitutionEnd(source, index);
2455
+ if (end === undefined) continue;
2456
+ const command = source.slice(index + 1, end).replaceAll("\\`", "`");
2457
+ if (command.length > 0 && !substitutions.has(command)) {
2458
+ substitutions.add(command);
2459
+ pendingSources.push(command);
2460
+ }
2461
+ index = end;
2462
+ }
2463
+ }
189
2464
  }
190
- return undefined;
2465
+ return [...substitutions];
191
2466
  }
192
2467
 
193
- function collectShellPayloads(tokens: Token[]): string[] {
194
- const payloads: string[] = [];
195
- for (let index = 0; index < tokens.length; index++) {
196
- const token = tokens[index];
197
- if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
198
- for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
199
- if (tokens[cursor].text === "-c" && tokens[cursor + 1]?.quoted) {
200
- payloads.push(tokens[cursor + 1].text);
2468
+ function hereDocumentPipelineSegments(tokens: Token[], hereDocument: HereDocument): Set<number> {
2469
+ const groupedRedirect = !tokens.some(token => token.segment === hereDocument.segment && token.commandStart);
2470
+ return new Set(
2471
+ tokens
2472
+ .filter(
2473
+ token =>
2474
+ token.pipeline === hereDocument.pipeline &&
2475
+ (!groupedRedirect || token.subshellDepth > hereDocument.subshellDepth),
2476
+ )
2477
+ .map(token => token.segment),
2478
+ );
2479
+ }
2480
+
2481
+ function collectShellStdinPayloads(
2482
+ tokens: Token[],
2483
+ hereDocuments: HereDocument[],
2484
+ scripts: ShellScript[],
2485
+ cwd: string,
2486
+ depth = 0,
2487
+ cwdUnknown = false,
2488
+ env: Record<string, string | undefined> = process.env,
2489
+ ): ShellPayload[] {
2490
+ const payloads: ShellPayload[] = [];
2491
+ for (const hereDocument of hereDocuments) {
2492
+ if (
2493
+ (hereDocument.kind === "here-document" && hereDocument.delimiterQuoted) ||
2494
+ (hereDocument.kind === "here-string" && !hereDocument.expandsCommandSubstitutions)
2495
+ ) {
2496
+ continue;
2497
+ }
2498
+ for (const content of collectHereDocumentCommandSubstitutions(joinUnquotedHereDocumentLines(hereDocument.body))) {
2499
+ payloads.push({ content, segment: hereDocument.segment, cwd, cwdUnknown });
2500
+ }
2501
+ }
2502
+ const executableHereDocuments = new Set<HereDocument>();
2503
+ for (let index = 0; index < hereDocuments.length; index++) {
2504
+ const hereDocument = hereDocuments[index];
2505
+ if (hereDocument.body.length === 0) continue;
2506
+ const segmentTokens = tokens.filter(token => token.segment === hereDocument.segment);
2507
+ const usedAsScript =
2508
+ scripts.some(
2509
+ script =>
2510
+ script.segment === hereDocument.segment &&
2511
+ script.fileDescriptor !== undefined &&
2512
+ hereDocument.activeDescriptors.has(script.fileDescriptor),
2513
+ ) ||
2514
+ shellPayloadUsesScriptDescriptor(
2515
+ collectShellPayloads(segmentTokens),
2516
+ hereDocument.activeDescriptors,
2517
+ cwd,
2518
+ depth,
2519
+ );
2520
+ if (usedAsScript) {
2521
+ executableHereDocuments.add(hereDocument);
2522
+ continue;
2523
+ }
2524
+ if (!hereDocument.stdin) {
2525
+ const consumedAsShellInput = [...hereDocument.stdinRedirectOrders].some(order =>
2526
+ hasShellStdinConsumer(tokens, hereDocument.segment, 0, order),
2527
+ );
2528
+ if (consumedAsShellInput) executableHereDocuments.add(hereDocument);
2529
+ continue;
2530
+ }
2531
+ const commandToken = tokens.find(token => token.segment === hereDocument.segment && token.commandStart);
2532
+ if (commandToken && commandToken.stdinRedirectOrder !== hereDocument.redirectOrder) continue;
2533
+ const pipelineSegments = hereDocumentPipelineSegments(tokens, hereDocument);
2534
+ let consumed = false;
2535
+ for (const segment of pipelineSegments) {
2536
+ const sourceOrder = segment === hereDocument.segment ? hereDocument.redirectOrder : undefined;
2537
+ if (hasShellStdinConsumer(tokens, segment, 0, sourceOrder)) {
2538
+ consumed = true;
201
2539
  break;
202
2540
  }
203
2541
  }
2542
+ if (consumed) executableHereDocuments.add(hereDocument);
2543
+ }
2544
+
2545
+ let addedScriptSource = true;
2546
+ while (addedScriptSource) {
2547
+ addedScriptSource = false;
2548
+ for (const scriptDocument of executableHereDocuments) {
2549
+ for (const candidate of hereDocuments) {
2550
+ if (candidate.body.length === 0 || executableHereDocuments.has(candidate)) continue;
2551
+ if (
2552
+ scriptDocument.segment === candidate.segment &&
2553
+ shellPayloadUsesScriptDescriptor([scriptDocument.body], candidate.activeDescriptors, cwd, depth)
2554
+ ) {
2555
+ executableHereDocuments.add(candidate);
2556
+ addedScriptSource = true;
2557
+ }
2558
+ }
2559
+ }
2560
+ }
2561
+ for (const hereDocument of executableHereDocuments) {
2562
+ const script = scripts.find(
2563
+ candidate =>
2564
+ candidate.segment === hereDocument.segment &&
2565
+ candidate.fileDescriptor !== undefined &&
2566
+ hereDocument.activeDescriptors.has(candidate.fileDescriptor),
2567
+ );
2568
+ let payloadCwd = script?.cwd ?? cwd;
2569
+ let payloadCwdUnknown = script?.cwdUnknown ?? cwdUnknown;
2570
+ const candidateSegments = hereDocument.stdin
2571
+ ? hereDocumentPipelineSegments(tokens, hereDocument)
2572
+ : new Set([hereDocument.segment]);
2573
+ for (const segment of candidateSegments) {
2574
+ const redirectOrders = hereDocument.stdin
2575
+ ? [segment === hereDocument.segment ? hereDocument.redirectOrder : undefined]
2576
+ : [...hereDocument.stdinRedirectOrders];
2577
+ let consumerIndex: number | undefined;
2578
+ for (const order of redirectOrders) {
2579
+ consumerIndex = shellStdinConsumerIndex(tokens, segment, order);
2580
+ if (consumerIndex !== undefined) break;
2581
+ }
2582
+ if (consumerIndex === undefined) continue;
2583
+ const commandCwd = commandWorkingDirectory(tokens, consumerIndex, cwd, env);
2584
+ payloadCwd = commandCwd ?? cwd;
2585
+ payloadCwdUnknown = cwdUnknown || commandCwd === undefined;
2586
+ break;
2587
+ }
2588
+ payloads.push({
2589
+ content: hereDocument.body,
2590
+ segment: hereDocument.segment,
2591
+ cwd: payloadCwd,
2592
+ cwdUnknown: payloadCwdUnknown,
2593
+ });
204
2594
  }
205
2595
  return payloads;
206
2596
  }
207
2597
 
208
- function collectShellScripts(tokens: Token[], cwd: string): string[] {
209
- const scripts: string[] = [];
2598
+ function scriptFileDescriptor(scriptPath: string): number | undefined {
2599
+ const match = /^(?:\/dev\/fd|\/proc\/(?:self|thread-self|\$\$)\/fd)\/(\d+)$/.exec(scriptPath);
2600
+ if (!match) return undefined;
2601
+ const descriptor = Number(match[1]);
2602
+ return Number.isSafeInteger(descriptor) ? descriptor : undefined;
2603
+ }
2604
+
2605
+ function collectShellScripts(
2606
+ tokens: Token[],
2607
+ cwd: string,
2608
+ cwdUnknown = false,
2609
+ env: Record<string, string | undefined> = process.env,
2610
+ ): ShellScript[] {
2611
+ const scripts: ShellScript[] = [];
2612
+ const optionsWithArg = new Set(["-O", "-o", "+O", "+o"]);
2613
+
210
2614
  for (let index = 0; index < tokens.length; index++) {
211
2615
  const token = tokens[index];
212
- if (token.commandStart && (token.text.startsWith("./") || token.text.startsWith("/"))) {
213
- scripts.push(path.resolve(cwd, token.text));
2616
+ if (!token.commandStart || token.executionSuppressed || isCommandLookup(tokens, index)) continue;
2617
+ const name = commandName(token.text);
2618
+ const commandCwd = commandWorkingDirectory(tokens, index, cwd, env);
2619
+ const scriptCwd = commandCwd ?? cwd;
2620
+ const scriptCwdUnknown = cwdUnknown || commandCwd === undefined;
2621
+ if (token.text === "." || name === "source") {
2622
+ for (let cursor = index + 1; cursor < tokens.length; cursor++) {
2623
+ const argument = tokens[cursor];
2624
+ if (argument.segment !== token.segment || argument.commandStart) break;
2625
+ if (argument.isRedirectionTarget || argument.text === "--") continue;
2626
+ const fileDescriptor = scriptFileDescriptor(argument.text);
2627
+ scripts.push({
2628
+ segment: token.segment,
2629
+ path: fileDescriptor === undefined ? path.resolve(scriptCwd, argument.text) : argument.text,
2630
+ ...(fileDescriptor !== undefined && { fileDescriptor }),
2631
+ cwd: scriptCwd,
2632
+ cwdUnknown: scriptCwdUnknown,
2633
+ pathUnknown: fileDescriptor === undefined && !path.isAbsolute(argument.text) && scriptCwdUnknown,
2634
+ });
2635
+ break;
2636
+ }
214
2637
  continue;
215
2638
  }
216
- if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
217
- for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
2639
+ if (token.text.includes("/")) {
2640
+ const fileDescriptor = scriptFileDescriptor(token.text);
2641
+ scripts.push({
2642
+ segment: token.segment,
2643
+ path: path.resolve(scriptCwd, token.text),
2644
+ ...(fileDescriptor !== undefined && { fileDescriptor }),
2645
+ cwd: scriptCwd,
2646
+ cwdUnknown: scriptCwdUnknown,
2647
+ pathUnknown: fileDescriptor === undefined && !path.isAbsolute(token.text) && scriptCwdUnknown,
2648
+ });
2649
+ if (!SHELL_RUNNERS.has(name) && name !== "busybox") continue;
2650
+ }
2651
+ if (!token.text.includes("/") && (SHELL_RUNNERS.has(name) || name === "busybox")) {
2652
+ const commandPath = commandEnvironmentValue(tokens, index, "PATH", env);
2653
+ let pathValue = commandPath.value ?? "";
2654
+ let pathUnknownReason: string | undefined;
2655
+ if (
2656
+ commandPath.assignment &&
2657
+ !commandPath.assignment.hasLiteralShellExpansion &&
2658
+ hasShellPathExpansion(pathValue)
2659
+ ) {
2660
+ const expandedPath = expandKnownShellPath(pathValue, env);
2661
+ if (expandedPath === undefined) {
2662
+ pathUnknownReason = "its PATH override contains an unresolved shell expansion";
2663
+ } else {
2664
+ pathValue = expandedPath;
2665
+ }
2666
+ }
2667
+ if (pathUnknownReason !== undefined) {
2668
+ scripts.push({
2669
+ segment: token.segment,
2670
+ path: token.text,
2671
+ cwd: scriptCwd,
2672
+ cwdUnknown: scriptCwdUnknown,
2673
+ pathUnknown: true,
2674
+ pathUnknownReason,
2675
+ });
2676
+ } else {
2677
+ const resolvedPath = Bun.which(token.text, { PATH: pathValue, cwd: scriptCwd });
2678
+ if (resolvedPath !== null) {
2679
+ scripts.push({
2680
+ segment: token.segment,
2681
+ path: resolvedPath,
2682
+ cwd: scriptCwd,
2683
+ cwdUnknown: scriptCwdUnknown,
2684
+ });
2685
+ }
2686
+ }
2687
+ }
2688
+ // Handle shell runners (bash, sh, etc.) and busybox applets
2689
+ let shellIndex = index;
2690
+ if (commandName(token.text) === "busybox") {
2691
+ // For busybox, find the applet name
2692
+ for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
2693
+ const arg = tokens[cursor].text;
2694
+ if (arg.startsWith("-")) continue; // Skip busybox options
2695
+ // This is the applet name
2696
+ if (SHELL_RUNNERS.has(arg)) {
2697
+ shellIndex = cursor;
2698
+ }
2699
+ break;
2700
+ }
2701
+ }
2702
+
2703
+ if (!SHELL_RUNNERS.has(commandName(tokens[shellIndex].text))) continue;
2704
+
2705
+ // Skip past all options (short, long, and their arguments) to find the script argument
2706
+ let skipNextArg = false;
2707
+ let skipNextOption: string | undefined;
2708
+ let optionsTerminated = false;
2709
+ let noExecMode = false;
2710
+ for (let cursor = shellIndex + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
218
2711
  const argument = tokens[cursor].text;
219
- if (argument === "-c") break;
220
- if (argument.startsWith("-")) continue;
221
- scripts.push(path.resolve(cwd, argument));
2712
+
2713
+ // If the previous option takes an argument, skip this word
2714
+ if (skipNextArg) {
2715
+ skipNextArg = false;
2716
+ if (skipNextOption === "-o" && argument === "noexec") noExecMode = true;
2717
+ else if (skipNextOption === "+o" && argument === "noexec") noExecMode = false;
2718
+ skipNextOption = undefined;
2719
+ continue;
2720
+ }
2721
+ if (optionsTerminated) {
2722
+ const fileDescriptor = scriptFileDescriptor(argument);
2723
+ if (!noExecMode) {
2724
+ scripts.push({
2725
+ segment: token.segment,
2726
+ path: fileDescriptor === undefined ? path.resolve(scriptCwd, argument) : argument,
2727
+ ...(fileDescriptor !== undefined && { fileDescriptor }),
2728
+ cwd: scriptCwd,
2729
+ cwdUnknown: scriptCwdUnknown,
2730
+ pathUnknown: fileDescriptor === undefined && !path.isAbsolute(argument) && scriptCwdUnknown,
2731
+ });
2732
+ }
2733
+ break;
2734
+ }
2735
+ if (argument === "--dump-strings") {
2736
+ noExecMode = true;
2737
+ continue;
2738
+ }
2739
+ if (shellOptionDisablesExecution(argument)) {
2740
+ noExecMode = true;
2741
+ continue;
2742
+ }
2743
+ if (argument === "--help" || argument === "--version") break;
2744
+ if (argument === "--") {
2745
+ optionsTerminated = true;
2746
+ continue;
2747
+ }
2748
+ if (argument === "+O" || argument === "+o" || argument === "-O" || argument === "-o") {
2749
+ skipNextArg = true;
2750
+ skipNextOption = argument;
2751
+ continue;
2752
+ }
2753
+ if (argument.startsWith("+O") || argument.startsWith("+o")) {
2754
+ if (argument.startsWith("+o") && argument.slice(2) === "noexec") noExecMode = false;
2755
+ continue;
2756
+ }
2757
+ if (argument.startsWith("-o") && argument.length > 2) {
2758
+ if (argument.slice(2) === "noexec") noExecMode = true;
2759
+ continue;
2760
+ }
2761
+ if (isPositiveShellOption(argument)) {
2762
+ if (shellOptionEnablesExecution(argument)) noExecMode = false;
2763
+ continue;
2764
+ }
2765
+ if (argument.startsWith("+") && !argument.startsWith("++")) {
2766
+ const flags = argument.slice(1);
2767
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
2768
+ if (operandOptionIndex >= 0) {
2769
+ const option = flags[operandOptionIndex] === "o" ? "+o" : "+O";
2770
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
2771
+ if (attachedOperand.length === 0) {
2772
+ skipNextArg = true;
2773
+ skipNextOption = option;
2774
+ } else if (option === "+o" && attachedOperand === "noexec") {
2775
+ noExecMode = false;
2776
+ }
2777
+ continue;
2778
+ }
2779
+ }
2780
+ if (shellOptionDisablesExecution(argument)) noExecMode = true;
2781
+
2782
+ // A command string or stdin option means there is no script-file operand.
2783
+ const shortOption = argument.startsWith("-") && !argument.startsWith("--");
2784
+ const bundledOption =
2785
+ shortOption && argument.length > 2 && !argument.startsWith("-O") && !argument.startsWith("-o");
2786
+ if (
2787
+ argument === "-" ||
2788
+ argument === "-s" ||
2789
+ argument === "-c" ||
2790
+ (bundledOption && (argument.includes("s") || argument.includes("c")))
2791
+ ) {
2792
+ break;
2793
+ }
2794
+ if (argument.startsWith("-") && !argument.startsWith("--")) {
2795
+ const flags = argument.slice(1);
2796
+ const operandOptionIndex = [...flags].findIndex(option => option === "O" || option === "o");
2797
+ if (operandOptionIndex >= 0) {
2798
+ const option = flags[operandOptionIndex] === "o" ? "-o" : "-O";
2799
+ const attachedOperand = flags.slice(operandOptionIndex + 1);
2800
+ if (attachedOperand.length === 0) {
2801
+ skipNextArg = true;
2802
+ skipNextOption = option;
2803
+ } else if (option === "-o" && attachedOperand === "noexec") {
2804
+ noExecMode = true;
2805
+ }
2806
+ continue;
2807
+ }
2808
+ }
2809
+
2810
+ // Skip options and their arguments
2811
+ if (argument.startsWith("-")) {
2812
+ // Check if this is a long option
2813
+ if (argument.startsWith("--")) {
2814
+ // Handle long options with = (e.g., --rcfile=FILE)
2815
+ if (argument.includes("=")) {
2816
+ skipNextArg = false;
2817
+ continue;
2818
+ }
2819
+ // Handle long options that take an argument
2820
+ if (SHELL_LONG_OPTIONS_WITH_ARG.has(argument)) {
2821
+ skipNextArg = true;
2822
+ continue;
2823
+ }
2824
+ // All other long options are flag-only
2825
+ continue;
2826
+ }
2827
+
2828
+ // Check if this short option takes an argument
2829
+ if (optionsWithArg.has(argument)) {
2830
+ skipNextArg = true;
2831
+ continue;
2832
+ }
2833
+
2834
+ // Handle options with bundled operands (e.g., -Oextglob, -opipefail)
2835
+ if (
2836
+ (argument.startsWith("-O") && argument.length > 2) ||
2837
+ (argument.startsWith("-o") && argument.length > 2)
2838
+ ) {
2839
+ continue;
2840
+ }
2841
+
2842
+ continue; // Skip all other options
2843
+ }
2844
+
2845
+ // The shell's first non-option argument is the script file
2846
+ const fileDescriptor = scriptFileDescriptor(argument);
2847
+ if (!noExecMode) {
2848
+ scripts.push({
2849
+ segment: token.segment,
2850
+ path: fileDescriptor === undefined ? path.resolve(scriptCwd, argument) : argument,
2851
+ ...(fileDescriptor !== undefined && { fileDescriptor }),
2852
+ cwd: scriptCwd,
2853
+ cwdUnknown: scriptCwdUnknown,
2854
+ pathUnknown: fileDescriptor === undefined && !path.isAbsolute(argument) && scriptCwdUnknown,
2855
+ });
2856
+ }
222
2857
  break;
223
2858
  }
224
2859
  }
225
2860
  return scripts;
226
2861
  }
227
2862
 
2863
+ function shellDescriptorsClosedBefore(tokens: Token[], segment: number): Set<number> {
2864
+ const closed = new Set<number>();
2865
+ let conditionalDepth = 0;
2866
+ let loopDepth = 0;
2867
+ let caseDepth = 0;
2868
+ for (let index = 0; index < tokens.length; index++) {
2869
+ const command = tokens[index];
2870
+ if (command.segment >= segment) break;
2871
+ if (command.commandStart) {
2872
+ switch (command.text) {
2873
+ case "if":
2874
+ conditionalDepth++;
2875
+ continue;
2876
+ case "fi":
2877
+ conditionalDepth = Math.max(0, conditionalDepth - 1);
2878
+ continue;
2879
+ case "for":
2880
+ case "while":
2881
+ case "until":
2882
+ case "select":
2883
+ loopDepth++;
2884
+ continue;
2885
+ case "done":
2886
+ loopDepth = Math.max(0, loopDepth - 1);
2887
+ continue;
2888
+ case "case":
2889
+ caseDepth++;
2890
+ continue;
2891
+ case "esac":
2892
+ caseDepth = Math.max(0, caseDepth - 1);
2893
+ continue;
2894
+ }
2895
+ }
2896
+ if (
2897
+ !command.commandStart ||
2898
+ commandName(command.text) !== "exec" ||
2899
+ command.conditionallyExecuted === true ||
2900
+ command.subshellDepth > 0 ||
2901
+ conditionalDepth > 0 ||
2902
+ loopDepth > 0 ||
2903
+ caseDepth > 0 ||
2904
+ tokens.some(
2905
+ other => other.commandStart && other.pipeline === command.pipeline && other.segment !== command.segment,
2906
+ )
2907
+ ) {
2908
+ continue;
2909
+ }
2910
+ for (let cursor = index + 1; cursor < tokens.length && tokens[cursor].segment === command.segment; cursor++) {
2911
+ const redirection = tokens[cursor];
2912
+ if (!redirection.isRedirectionTarget || redirection.redirectionFd === undefined) continue;
2913
+ if (redirection.redirectionIsDuplication && redirection.text === "-") {
2914
+ closed.add(redirection.redirectionFd);
2915
+ } else {
2916
+ closed.delete(redirection.redirectionFd);
2917
+ }
2918
+ }
2919
+ }
2920
+ return closed;
2921
+ }
2922
+
2923
+ function shellPayloadUsesScriptDescriptor(
2924
+ payloads: string[],
2925
+ activeDescriptors: Set<number>,
2926
+ cwd: string,
2927
+ depth = 0,
2928
+ ): boolean {
2929
+ if (depth > MAX_INDIRECTION_DEPTH) return false;
2930
+ for (const payload of payloads) {
2931
+ const nested = tokenize(payload);
2932
+ const nestedTokens = nested.tokens;
2933
+ const nestedScripts = collectShellScripts(nestedTokens, cwd);
2934
+ if (
2935
+ nestedScripts.some(
2936
+ script =>
2937
+ script.fileDescriptor !== undefined &&
2938
+ activeDescriptors.has(script.fileDescriptor) &&
2939
+ !shellDescriptorsClosedBefore(nestedTokens, script.segment).has(script.fileDescriptor),
2940
+ )
2941
+ ) {
2942
+ return true;
2943
+ }
2944
+ if (depth < MAX_INDIRECTION_DEPTH) {
2945
+ for (const shellPayload of collectShellPayloadsWithContext(nestedTokens)) {
2946
+ const closed = shellDescriptorsClosedBefore(nestedTokens, shellPayload.segment);
2947
+ const available = new Set([...activeDescriptors].filter(descriptor => !closed.has(descriptor)));
2948
+ if (shellPayloadUsesScriptDescriptor([shellPayload.content], available, cwd, depth + 1)) return true;
2949
+ }
2950
+ }
2951
+ if (depth < MAX_INDIRECTION_DEPTH) {
2952
+ const stdinPayloads = collectShellStdinPayloads(
2953
+ nestedTokens,
2954
+ nested.hereDocuments,
2955
+ nestedScripts,
2956
+ cwd,
2957
+ depth + 1,
2958
+ );
2959
+ for (const hereDocument of nested.hereDocuments) {
2960
+ const hereDocumentPayloads =
2961
+ (hereDocument.kind === "here-document" && hereDocument.delimiterQuoted) ||
2962
+ (hereDocument.kind === "here-string" && !hereDocument.expandsCommandSubstitutions)
2963
+ ? []
2964
+ : collectHereDocumentCommandSubstitutions(joinUnquotedHereDocumentLines(hereDocument.body));
2965
+ if (stdinPayloads.some(payload => payload.content === hereDocument.body)) {
2966
+ hereDocumentPayloads.push(hereDocument.body);
2967
+ }
2968
+ if (hereDocumentPayloads.length === 0) continue;
2969
+ const closed = shellDescriptorsClosedBefore(nestedTokens, hereDocument.segment);
2970
+ const available = new Set([...activeDescriptors].filter(descriptor => !closed.has(descriptor)));
2971
+ if (shellPayloadUsesScriptDescriptor(hereDocumentPayloads, available, cwd, depth + 1)) return true;
2972
+ }
2973
+ }
2974
+ }
2975
+ return false;
2976
+ }
2977
+
228
2978
  interface Identity {
229
2979
  socketPath: string;
230
2980
  paneId: string;
@@ -244,7 +2994,7 @@ function socketPathFor(
244
2994
  uid: number,
245
2995
  ): string {
246
2996
  if (socket.flag === "-S") return path.isAbsolute(socket.value) ? socket.value : path.resolve(cwd, socket.value);
247
- return path.join(env.TMUX_TMPDIR ?? "/tmp", `tmux-${uid}`, socket.value);
2997
+ return path.join(env.TMUX_TMPDIR || "/tmp", `tmux-${uid}`, socket.value);
248
2998
  }
249
2999
 
250
3000
  function sameSocket(left: string, right: string): boolean {
@@ -256,15 +3006,351 @@ function sameSocket(left: string, right: string): boolean {
256
3006
  }
257
3007
  }
258
3008
 
259
- function assignedTmuxSocket(tokens: Token[], commandIndex: number): string | undefined {
260
- for (let index = commandIndex - 1; index >= 0 && commandIndex - index <= 4; index--) {
261
- const assignment = tokens[index].text.match(/^TMUX=([^,\s]+)/);
262
- if (assignment) return assignment[1];
263
- if (!tokens[index].commandStart) break;
3009
+ function envWrapperClearsVariable(
3010
+ tokens: Token[],
3011
+ envIndex: number,
3012
+ commandIndex: number,
3013
+ name: "TMUX" | "TMUX_TMPDIR",
3014
+ ): boolean {
3015
+ const segment = tokens[envIndex].segment;
3016
+ for (let index = envIndex + 1; index < commandIndex; index++) {
3017
+ const token = tokens[index];
3018
+ if (token.segment !== segment || token.commandStart) break;
3019
+ if (token.isRedirectionTarget) continue;
3020
+
3021
+ const argument = token.text;
3022
+ if (argument === "--") break;
3023
+ if (argument === "-i" || argument === "--ignore-environment") return true;
3024
+ if (argument === "-u" || argument === "--unset") {
3025
+ const operand = tokens[index + 1];
3026
+ if (operand && operand.segment === segment && !operand.commandStart) {
3027
+ if (operand.text === name) return true;
3028
+ index++;
3029
+ }
3030
+ continue;
3031
+ }
3032
+ if (argument.startsWith("--unset=")) {
3033
+ if (argument.slice("--unset=".length) === name) return true;
3034
+ continue;
3035
+ }
3036
+ if (argument.startsWith("--")) {
3037
+ if (wrapperOptionTakesNextArgument({ name: "env", spec: COMMAND_WRAPPERS_WITH_ARGS.env }, argument)) index++;
3038
+ continue;
3039
+ }
3040
+ if (argument.startsWith("-") && argument.length > 1) {
3041
+ for (let optionIndex = 1; optionIndex < argument.length; optionIndex++) {
3042
+ const option = `-${argument[optionIndex]}`;
3043
+ if (option === "-i") return true;
3044
+ if (option === "-u") {
3045
+ const attachedOperand = argument.slice(optionIndex + 1);
3046
+ const operand = attachedOperand || tokens[index + 1]?.text;
3047
+ if (attachedOperand.length === 0) index++;
3048
+ if (operand === name) return true;
3049
+ break;
3050
+ }
3051
+ if (COMMAND_WRAPPERS_WITH_ARGS.env.optionsWithArg.has(option)) {
3052
+ if (optionIndex === argument.length - 1) index++;
3053
+ break;
3054
+ }
3055
+ }
3056
+ continue;
3057
+ }
3058
+ if (!isAssignment(argument)) break;
3059
+ }
3060
+ return false;
3061
+ }
3062
+
3063
+ function envClearsVariable(tokens: Token[], commandIndex: number, name: "TMUX" | "TMUX_TMPDIR"): boolean {
3064
+ const commandSegment = tokens[commandIndex].segment;
3065
+ for (let index = commandIndex - 1; index >= 0; index--) {
3066
+ const token = tokens[index];
3067
+ if (token.segment !== commandSegment) break;
3068
+ if (
3069
+ token.commandStart &&
3070
+ commandName(token.text) === "env" &&
3071
+ envWrapperClearsVariable(tokens, index, commandIndex, name)
3072
+ ) {
3073
+ return true;
3074
+ }
3075
+ }
3076
+ return false;
3077
+ }
3078
+
3079
+ function assignedTmuxEnvironmentValue(
3080
+ tokens: Token[],
3081
+ commandIndex: number,
3082
+ name: "TMUX" | "TMUX_TMPDIR",
3083
+ ): string | undefined {
3084
+ // Backtrack through wrappers in the current simple command. Wrapper option
3085
+ // operands that look like assignments are not environment overrides.
3086
+
3087
+ const commandSegment = tokens[commandIndex].segment;
3088
+ let clearedByEnv = false;
3089
+ for (let index = commandIndex - 1; index >= 0; index--) {
3090
+ const token = tokens[index];
3091
+ if (token.segment !== commandSegment) break;
3092
+ if (token.commandStart && commandName(token.text) === "env") {
3093
+ clearedByEnv ||= envWrapperClearsVariable(tokens, index, commandIndex, name);
3094
+ }
3095
+
3096
+ const assignment = name === "TMUX" ? token.text.match(/^TMUX=([^,\s]+)/) : token.text.match(/^TMUX_TMPDIR=(.*)$/);
3097
+ let isOptionOperand = false;
3098
+ // Note: assignments at commandStart are valid (e.g., 'TMUX=...');
3099
+ // we only need to check for option operands when it's NOT at commandStart
3100
+ if (assignment && !token.commandStart) {
3101
+ // Before accepting this as a TMUX assignment, check if it's an operand to a preceding option
3102
+ // Look back one position to see if there's an option that would consume this
3103
+ if (index > 0) {
3104
+ const precedingToken = tokens[index - 1];
3105
+ if (precedingToken.text.startsWith("-") && !precedingToken.commandStart) {
3106
+ // The preceding token is an option; check if it takes an argument
3107
+ const optText = precedingToken.text;
3108
+
3109
+ // Handle wrapper options that take arguments
3110
+ // This is a conservative check: if it's a flag starting with -, it might consume the next argument
3111
+ let optionTakesArg = false;
3112
+
3113
+ // Find the wrapper within the current command, regardless of option count.
3114
+ for (let wrapIdx = index - 1; wrapIdx >= 0; wrapIdx--) {
3115
+ if (tokens[wrapIdx].commandStart) {
3116
+ const baseName = tokens[wrapIdx].text.includes("/")
3117
+ ? tokens[wrapIdx].text.split("/").pop() || tokens[wrapIdx].text
3118
+ : tokens[wrapIdx].text;
3119
+ const wrapper = COMMAND_WRAPPERS_WITH_ARGS[baseName];
3120
+ if (wrapper) {
3121
+ optionTakesArg = wrapperOptionTakesNextArgument({ name: baseName, spec: wrapper }, optText);
3122
+ }
3123
+ break;
3124
+ }
3125
+ }
3126
+
3127
+ isOptionOperand = optionTakesArg;
3128
+ }
3129
+ }
3130
+ }
3131
+
3132
+ if (assignment) {
3133
+ // Accept TMUX assignments that are:
3134
+ // 1. At commandStart (e.g., 'TMUX=...' at the beginning of the command)
3135
+ // 2. Not at commandStart but also not option operands (e.g., assignments between wrappers)
3136
+ if (token.commandStart || !isOptionOperand) {
3137
+ // Found a genuine environment assignment; it applies to this invocation
3138
+ return clearedByEnv ? undefined : assignment[1];
3139
+ }
3140
+ }
3141
+
3142
+ // Track command boundaries
3143
+ if (token.commandStart) {
3144
+ // Normalize the wrapper name (handle path-qualified commands)
3145
+ const baseName = token.text.includes("/") ? token.text.split("/").pop() || token.text : token.text;
3146
+ // Skip assignments (they have commandStart=true but aren't command boundaries)
3147
+ const isAssignmentToken = isAssignment(token.text);
3148
+ if (!isAssignmentToken && !COMMAND_WRAPPERS.has(baseName) && !COMMAND_WRAPPERS_WITH_ARGS[baseName]) {
3149
+ // Hit a non-wrapper, non-assignment command start; this marks the boundary of a previous invocation
3150
+ // Stop searching - assignments found here belong to a different command
3151
+ break;
3152
+ }
3153
+ // Continue searching through wrapper commands and assignments
3154
+ }
264
3155
  }
265
3156
  return undefined;
266
3157
  }
267
3158
 
3159
+ function envWrapperWorkingDirectory(
3160
+ tokens: Token[],
3161
+ envIndex: number,
3162
+ cwd: string,
3163
+ env: Record<string, string | undefined> = process.env,
3164
+ ): string | undefined {
3165
+ let effectiveCwd = cwd;
3166
+ const envToken = tokens[envIndex];
3167
+ if (!envToken) return undefined;
3168
+ for (let cursor = envIndex + 1; cursor < tokens.length; cursor++) {
3169
+ const argument = tokens[cursor];
3170
+ if (argument.segment !== envToken.segment || argument.commandStart) break;
3171
+ if (argument.isRedirectionTarget || argument.text === "--") {
3172
+ if (argument.text === "--") break;
3173
+ continue;
3174
+ }
3175
+
3176
+ let chdir: string | undefined;
3177
+ let chdirToken: Token | undefined;
3178
+ if (argument.text === "-C") {
3179
+ const value = tokens[cursor + 1];
3180
+ if (value && value.segment === envToken.segment && !value.commandStart) {
3181
+ chdir = value.text;
3182
+ chdirToken = value;
3183
+ cursor++;
3184
+ }
3185
+ } else if (argument.text.startsWith("-C") && argument.text.length > 2) {
3186
+ chdir = argument.text.slice(2);
3187
+ chdirToken = argument;
3188
+ } else if (argument.text.startsWith("--")) {
3189
+ const equalsIndex = argument.text.indexOf("=");
3190
+ const optionName = matchingWrapperLongOption(
3191
+ "env",
3192
+ equalsIndex < 0 ? argument.text : argument.text.slice(0, equalsIndex),
3193
+ );
3194
+ if (optionName === "--chdir") {
3195
+ if (equalsIndex >= 0) {
3196
+ chdir = argument.text.slice(equalsIndex + 1);
3197
+ chdirToken = argument;
3198
+ } else {
3199
+ const value = tokens[cursor + 1];
3200
+ if (value && value.segment === envToken.segment && !value.commandStart) {
3201
+ chdir = value.text;
3202
+ chdirToken = value;
3203
+ cursor++;
3204
+ }
3205
+ }
3206
+ }
3207
+ } else if (!argument.text.startsWith("-") && !isAssignment(argument.text)) {
3208
+ break;
3209
+ }
3210
+ if (
3211
+ chdir === undefined &&
3212
+ wrapperOptionTakesNextArgument({ name: "env", spec: COMMAND_WRAPPERS_WITH_ARGS.env }, argument.text)
3213
+ ) {
3214
+ cursor++;
3215
+ }
3216
+ if (chdir !== undefined) {
3217
+ if (hasShellPathExpansion(chdir)) {
3218
+ if (chdirToken?.hasLiteralShellExpansion) return undefined;
3219
+ const expandedChdir = expandKnownShellPath(chdir, env);
3220
+ if (expandedChdir === undefined) return undefined;
3221
+ chdir = expandedChdir;
3222
+ }
3223
+ effectiveCwd = path.resolve(effectiveCwd, chdir);
3224
+ }
3225
+ }
3226
+ return effectiveCwd;
3227
+ }
3228
+
3229
+ function commandEnvironmentValue(
3230
+ tokens: Token[],
3231
+ commandIndex: number,
3232
+ name: string,
3233
+ env: Record<string, string | undefined>,
3234
+ ): CommandEnvironmentValue {
3235
+ const commandSegment = tokens[commandIndex]?.segment;
3236
+ if (commandSegment === undefined) return { value: env[name] };
3237
+
3238
+ for (let index = commandIndex - 1; index >= 0; index--) {
3239
+ const token = tokens[index];
3240
+ if (token.segment !== commandSegment) break;
3241
+ const assignment = token.text.startsWith(`${name}=`) ? token.text.slice(name.length + 1) : undefined;
3242
+ if (assignment !== undefined) {
3243
+ let optionOperand = false;
3244
+ const precedingToken = tokens[index - 1];
3245
+ if (precedingToken?.text.startsWith("-") && !precedingToken.commandStart) {
3246
+ for (let wrapperIndex = index - 1; wrapperIndex >= 0; wrapperIndex--) {
3247
+ if (tokens[wrapperIndex].segment !== commandSegment) break;
3248
+ if (!tokens[wrapperIndex].commandStart) continue;
3249
+ const wrapperName = commandName(tokens[wrapperIndex].text);
3250
+ const spec = COMMAND_WRAPPERS_WITH_ARGS[wrapperName];
3251
+ if (spec) {
3252
+ optionOperand = wrapperOptionTakesNextArgument({ name: wrapperName, spec }, precedingToken.text);
3253
+ }
3254
+ break;
3255
+ }
3256
+ }
3257
+ if (!optionOperand) return { value: assignment, assignment: token };
3258
+ }
3259
+ if (token.commandStart) {
3260
+ const wrapperName = commandName(token.text);
3261
+ if (
3262
+ !isAssignment(token.text) &&
3263
+ !COMMAND_WRAPPERS.has(wrapperName) &&
3264
+ !COMMAND_WRAPPERS_WITH_ARGS[wrapperName]
3265
+ ) {
3266
+ break;
3267
+ }
3268
+ }
3269
+ }
3270
+ return { value: env[name] };
3271
+ }
3272
+
3273
+ function expandKnownShellPath(value: string, env: Record<string, string | undefined>): string | undefined {
3274
+ if (value.startsWith("~")) return undefined;
3275
+ let unresolvedVariable = false;
3276
+ const expanded = value.replace(
3277
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
3278
+ (match, braced, bare) => {
3279
+ const variable = env[braced ?? bare];
3280
+ if (variable === undefined) {
3281
+ unresolvedVariable = true;
3282
+ return match;
3283
+ }
3284
+ return variable;
3285
+ },
3286
+ );
3287
+ if (unresolvedVariable || hasShellPathExpansion(expanded)) return undefined;
3288
+ return expanded;
3289
+ }
3290
+
3291
+ function commandWorkingDirectory(
3292
+ tokens: Token[],
3293
+ commandIndex: number,
3294
+ cwd: string,
3295
+ env: Record<string, string | undefined> = process.env,
3296
+ ): string | undefined {
3297
+ let effectiveCwd = cwd;
3298
+ const commandSegment = tokens[commandIndex]?.segment;
3299
+ if (commandSegment === undefined) return undefined;
3300
+ for (let index = 0; index < commandIndex; index++) {
3301
+ const token = tokens[index];
3302
+ if (token.segment !== commandSegment || !token.commandStart || commandName(token.text) !== "env") continue;
3303
+ const envCwd = envWrapperWorkingDirectory(tokens, index, effectiveCwd, env);
3304
+ if (envCwd === undefined) return undefined;
3305
+ effectiveCwd = envCwd;
3306
+ }
3307
+ return effectiveCwd;
3308
+ }
3309
+
3310
+ function hasShellPathExpansion(value: string): boolean {
3311
+ if (value.startsWith("~")) return true;
3312
+ for (const character of value) {
3313
+ if (character !== "~" && isShellExpansionCharacter(character)) return true;
3314
+ }
3315
+ return false;
3316
+ }
3317
+
3318
+ /**
3319
+ * Check if the given index is part of a `command -v` or `command -V` lookup.
3320
+ * Such invocations are not executions, so tmux commands within them don't run.
3321
+ * Every operand in the same simple command is a lookup name; wrappers among
3322
+ * those operands must not turn later names into executions.
3323
+ */
3324
+ function isCommandLookup(tokens: Token[], targetIndex: number): boolean {
3325
+ const targetSegment = tokens[targetIndex].segment;
3326
+ for (let commandIdx = 0; commandIdx < targetIndex; commandIdx++) {
3327
+ if (
3328
+ !tokens[commandIdx].commandStart ||
3329
+ tokens[commandIdx].text !== "command" ||
3330
+ tokens[commandIdx].segment !== targetSegment
3331
+ ) {
3332
+ continue;
3333
+ }
3334
+
3335
+ let lookup = false;
3336
+ for (let i = commandIdx + 1; i < targetIndex; i++) {
3337
+ if (tokens[i].segment !== targetSegment) break;
3338
+ const token = tokens[i].text;
3339
+ if (token === "--") return lookup;
3340
+ if (/^-[pVv]+$/.test(token)) {
3341
+ lookup ||= token.includes("v") || token.includes("V");
3342
+ continue;
3343
+ }
3344
+ // Bash's `command` options stop at its first command operand. A later
3345
+ // `-v` belongs to that command (for example, `command env -v ...`).
3346
+ return lookup;
3347
+ }
3348
+ if (lookup) return true;
3349
+ }
3350
+
3351
+ return false;
3352
+ }
3353
+
268
3354
  const defaultResolvePaneId: TmuxPaneResolver = async ({ socketArgs, target, env }) => {
269
3355
  const processHandle = Bun.spawn(["tmux", ...socketArgs, "display-message", "-p", "-t", target, "#{pane_id}"], {
270
3356
  env,
@@ -289,20 +3375,68 @@ export async function checkTmuxSelfInjection(
289
3375
  depth = 0,
290
3376
  ): Promise<TmuxSelfInjectionResult> {
291
3377
  const env = options.env ?? process.env;
3378
+ const executionEnv = options.executionEnv ?? env;
3379
+ return checkTmuxSelfInjectionAtCwd(command, options, depth, options.cwd ?? executionEnv.PWD ?? ".", false);
3380
+ }
3381
+
3382
+ async function checkTmuxSelfInjectionAtCwd(
3383
+ command: string,
3384
+ options: TmuxSelfInjectionOptions,
3385
+ depth: number,
3386
+ cwd: string,
3387
+ cwdUnknown: boolean,
3388
+ ): Promise<TmuxSelfInjectionResult> {
3389
+ const env = options.env ?? process.env;
3390
+ const executionEnv = options.executionEnv ?? env;
292
3391
  const identity = currentIdentity(env);
293
3392
  if (!identity) return { block: false };
294
- const cwd = options.cwd ?? env.PWD ?? ".";
295
3393
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
296
3394
  const resolvePaneId = options.resolvePaneId ?? defaultResolvePaneId;
297
- const tokens = tokenize(command);
3395
+ const { tokens, hereDocuments } = tokenize(command);
298
3396
 
299
3397
  for (let index = 0; index < tokens.length; index++) {
300
- if (!tokens[index].commandStart || (tokens[index].text !== "tmux" && !tokens[index].text.endsWith("/tmux")))
3398
+ if (
3399
+ !tokens[index].commandStart ||
3400
+ tokens[index].executionSuppressed ||
3401
+ (tokens[index].text !== "tmux" && !tokens[index].text.endsWith("/tmux"))
3402
+ )
301
3403
  continue;
3404
+ // Skip tmux invocations that are part of a `command -v` lookup (not execution)
3405
+ if (isCommandLookup(tokens, index)) continue;
302
3406
  const invocation = parseInvocation(tokens.slice(index + 1));
303
- if (!invocation || !INPUT_VERBS.has(invocation.verb)) continue;
304
- const assignedSocket = assignedTmuxSocket(tokens, index);
305
- const commandSocket = invocation.socket ? socketPathFor(invocation.socket, env, cwd, uid) : assignedSocket;
3407
+ if (!invocation || invocation.nonExecuting) continue;
3408
+ const commandCwd = commandWorkingDirectory(tokens, index, cwd, executionEnv);
3409
+ const commandCwdUnknown = cwdUnknown || commandCwd === undefined;
3410
+ if (depth < MAX_INDIRECTION_DEPTH) {
3411
+ for (const shellCommand of invocation.shellCommands) {
3412
+ const result = await checkTmuxSelfInjectionAtCwd(
3413
+ shellCommand,
3414
+ options,
3415
+ depth + 1,
3416
+ commandCwd ?? cwd,
3417
+ commandCwdUnknown,
3418
+ );
3419
+ if (result.block) return result;
3420
+ }
3421
+ }
3422
+ if (!invocation.verb || !INPUT_VERBS.has(invocation.verb)) continue;
3423
+ const commandEnv = { ...executionEnv };
3424
+ const assignedTmux = assignedTmuxEnvironmentValue(tokens, index, "TMUX");
3425
+ const assignedTmpdir = assignedTmuxEnvironmentValue(tokens, index, "TMUX_TMPDIR");
3426
+ if (assignedTmpdir !== undefined) commandEnv.TMUX_TMPDIR = assignedTmpdir;
3427
+ else if (envClearsVariable(tokens, index, "TMUX_TMPDIR")) delete commandEnv.TMUX_TMPDIR;
3428
+ const socketPathIsUnknown =
3429
+ invocation.socket !== undefined &&
3430
+ (hasShellPathExpansion(invocation.socket.value) ||
3431
+ (invocation.socket.flag === "-S" && !path.isAbsolute(invocation.socket.value) && commandCwdUnknown) ||
3432
+ (invocation.socket.flag === "-L" && assignedTmpdir !== undefined && hasShellPathExpansion(assignedTmpdir)));
3433
+ const commandSocket = socketPathIsUnknown
3434
+ ? undefined
3435
+ : invocation.socket
3436
+ ? socketPathFor(invocation.socket, commandEnv, commandCwd ?? cwd, uid)
3437
+ : assignedTmux !== undefined && !hasShellPathExpansion(assignedTmux)
3438
+ ? assignedTmux
3439
+ : undefined;
306
3440
  if (commandSocket && !sameSocket(commandSocket, identity.socketPath)) continue;
307
3441
 
308
3442
  const target = targetFromArgs(invocation.args);
@@ -322,7 +3456,7 @@ export async function checkTmuxSelfInjection(
322
3456
  const resolved = await resolvePaneId({
323
3457
  socketArgs: invocation.socketArgs,
324
3458
  target,
325
- env: { ...env, TMUX: `${identity.socketPath},0,0` } as Record<string, string>,
3459
+ env: { ...commandEnv, TMUX: `${identity.socketPath},0,0` } as Record<string, string>,
326
3460
  });
327
3461
  if (resolved !== undefined && resolved !== identity.paneId) continue;
328
3462
  if (resolved === identity.paneId || resolved === undefined) {
@@ -338,16 +3472,61 @@ export async function checkTmuxSelfInjection(
338
3472
  }
339
3473
 
340
3474
  if (depth < MAX_INDIRECTION_DEPTH) {
341
- for (const payload of collectShellPayloads(tokens)) {
342
- const result = await checkTmuxSelfInjection(payload, options, depth + 1);
3475
+ for (const token of tokens) {
3476
+ for (const substitution of token.commandSubstitutions ?? []) {
3477
+ const result = await checkTmuxSelfInjectionAtCwd(substitution, options, depth + 1, cwd, cwdUnknown);
3478
+ if (result.block) return result;
3479
+ }
3480
+ }
3481
+ const shellScripts = collectShellScripts(tokens, cwd, cwdUnknown, options.executionEnv ?? env);
3482
+ for (const payload of collectShellStdinPayloads(
3483
+ tokens,
3484
+ hereDocuments,
3485
+ shellScripts,
3486
+ cwd,
3487
+ 0,
3488
+ cwdUnknown,
3489
+ executionEnv,
3490
+ )) {
3491
+ const result = await checkTmuxSelfInjectionAtCwd(
3492
+ payload.content,
3493
+ options,
3494
+ depth + 1,
3495
+ payload.cwd ?? cwd,
3496
+ payload.cwdUnknown ?? cwdUnknown,
3497
+ );
3498
+ if (result.block) return result;
3499
+ }
3500
+ for (const payload of collectShellPayloadsWithContext(tokens, cwd, cwdUnknown, executionEnv)) {
3501
+ const result = await checkTmuxSelfInjectionAtCwd(
3502
+ payload.content,
3503
+ options,
3504
+ depth + 1,
3505
+ payload.cwd ?? cwd,
3506
+ payload.cwdUnknown ?? cwdUnknown,
3507
+ );
343
3508
  if (result.block) return result;
344
3509
  }
345
- for (const scriptPath of collectShellScripts(tokens, cwd)) {
3510
+ for (const shellScript of shellScripts) {
3511
+ if (shellScript.fileDescriptor !== undefined) continue;
3512
+ if (shellScript.pathUnknown) {
3513
+ return {
3514
+ block: true,
3515
+ reason: `Blocked: could not resolve shell script ${shellScript.path} because ${shellScript.pathUnknownReason ?? "its working directory is unknown"}.`,
3516
+ };
3517
+ }
346
3518
  try {
347
- const file = Bun.file(scriptPath);
3519
+ const file = Bun.file(shellScript.path);
348
3520
  if (!(await file.exists()) || file.size > 1_000_000) continue;
349
3521
  const script = await file.text();
350
- const result = await checkTmuxSelfInjection(script, options, depth + 1);
3522
+ if (script.includes("\0")) continue;
3523
+ const result = await checkTmuxSelfInjectionAtCwd(
3524
+ script,
3525
+ options,
3526
+ depth + 1,
3527
+ shellScript.cwd ?? cwd,
3528
+ shellScript.cwdUnknown ?? cwdUnknown,
3529
+ );
351
3530
  if (result.block) return result;
352
3531
  } catch {
353
3532
  // An unreadable or missing script is not itself evidence that it