@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.
- package/CHANGELOG.md +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- 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):
|
|
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
|
|
55
|
-
let
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
104
|
-
|
|
105
|
-
quoted = true;
|
|
2353
|
+
if (character === '"') {
|
|
2354
|
+
doubleQuoted = true;
|
|
106
2355
|
continue;
|
|
107
2356
|
}
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
previous = ch;
|
|
2357
|
+
if (character === "`") {
|
|
2358
|
+
backtickQuoted = true;
|
|
111
2359
|
continue;
|
|
112
2360
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
wrapperPending = false;
|
|
117
|
-
previous = ch;
|
|
2361
|
+
if (character === "$" && source[index + 1] === "(") {
|
|
2362
|
+
depth++;
|
|
2363
|
+
index++;
|
|
118
2364
|
continue;
|
|
119
2365
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 (
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
let index =
|
|
147
|
-
|
|
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
|
-
|
|
2396
|
+
continue;
|
|
154
2397
|
}
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
2418
|
+
|
|
2419
|
+
if (slashCount % 2 === 1) {
|
|
2420
|
+
joined += "\\".repeat(slashCount - 1);
|
|
173
2421
|
index++;
|
|
174
2422
|
continue;
|
|
175
2423
|
}
|
|
176
|
-
|
|
2424
|
+
joined += `${"\\".repeat(slashCount)}\n`;
|
|
2425
|
+
index++;
|
|
177
2426
|
}
|
|
178
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
2465
|
+
return [...substitutions];
|
|
191
2466
|
}
|
|
192
2467
|
|
|
193
|
-
function
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
209
|
-
const
|
|
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
|
|
213
|
-
|
|
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 (
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 (
|
|
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 ||
|
|
304
|
-
const
|
|
305
|
-
const
|
|
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: { ...
|
|
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
|
|
342
|
-
const
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|