samagotchi 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -39,35 +39,70 @@ module Samagotchi
39
39
  class PairCommand
40
40
  # What runs a command on another's behalf, and the shell's keywords
41
41
  # before a command: skipped to find the program.
42
- WRAPPERS = %w[env exec command nohup time sudo nice xargs setsid timeout stdbuf caffeinate bundle ruby watch npx
43
- { ! then do else elif if while until].freeze
42
+ WRAPPERS = %w[env exec command builtin nohup time sudo nice xargs setsid timeout stdbuf caffeinate bundle ruby
43
+ watch npx bunx uvx xcrun arch script { ! then do else elif if while until].freeze
44
+ # Wrappers by two words: the program after `uv run`, `pnpm exec` …
45
+ SUBCOMMAND_WRAPPERS = { "uv" => %w[run], "poetry" => %w[run], "pdm" => %w[run], "hatch" => %w[run],
46
+ "pipx" => %w[run], "pnpm" => %w[exec dlx], "yarn" => %w[exec dlx], "npm" => %w[exec x],
47
+ "bun" => %w[x], "mise" => %w[exec x] }.freeze
48
+ # A wrapper's options whose value is the next word (.program_at): not
49
+ # the program (env -u X git, sudo -u root git, timeout -s KILL 5 git).
50
+ WRAPPER_VALUES = { "env" => %w[-u --unset -C --chdir -P -S --split-string], "exec" => %w[-a],
51
+ "sudo" => %w[-u -g -h -p -C -D -R -T -U -r -t --user --group --host --prompt --chdir],
52
+ "timeout" => %w[-s --signal -k --kill-after], "nice" => %w[--adjustment],
53
+ "xargs" => %w[-L -P -s -d -E -a --max-args --max-procs --delimiter --arg-file],
54
+ "stdbuf" => %w[-i -o --input --output --error], "caffeinate" => %w[-t -w],
55
+ "watch" => %w[--interval], "arch" => %w[-arch -d], "xcrun" => %w[-sdk --sdk --toolchain],
56
+ "script" => %w[-t -T -B -I -O -c --command], "npx" => %w[-c --call -p --package],
57
+ "npm" => %w[-c --call -p --package -w --workspace] }.freeze
58
+ # A wrapper's flags the generic -I/-r/-C/-e/-n rule would read as
59
+ # taking a value (xcrun -r is --run, sudo -n non-interactive; macOS's
60
+ # script -F is a flag too: not in WRAPPER_VALUES).
61
+ WRAPPER_FLAGS = { "xcrun" => %w[-r -n -l -v -f -k], "sudo" => %w[-n -E -H -i -s -k -b -P -S -A -e -l -v -K -B],
62
+ "env" => %w[-i -0 -v] }.freeze
44
63
  # chi's own invocations a pair turn may run: they print and exit.
45
64
  CHI_ALLOWED = [%w[--version], %w[--help]].freeze
46
- # chi named in a script, a value or an argument.
47
- CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}
65
+ # chi named in a script, a value or an argument, in any case (CHI runs
66
+ # chi on a case-insensitive disk: ShellLex.program_name).
67
+ CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}i
48
68
  SHELLS = %w[sh bash zsh dash fish ksh].freeze
49
69
  # Other interpreters a script can be handed to (-e/-c, a heredoc), by
50
70
  # name in any version's spelling (python3.12, pypy3, ruby3.3, perl5.36).
51
71
  INTERPRETER = /\A(?:python\d*(?:\.\d+)?|pypy\d*(?:\.\d+)?|ruby\d*(?:\.\d+)?|irb|node(?:js)?|deno|bun|
52
- perl\d*(?:\.\d+)*|php\d*(?:\.\d+)?|lua(?:jit|\d*(?:\.\d+)?)|osascript|jshell|Rscript|
53
- tclsh\d*(?:\.\d+)?|wish)\z/x
72
+ perl\d*(?:\.\d+)*|php\d*(?:\.\d+)?|lua(?:jit|\d*(?:\.\d+)?)|osascript|jshell|rscript|
73
+ tclsh\d*(?:\.\d+)?|wish|jruby|raku|julia)\z/x
54
74
  # The options whose next word is an interpreter's code, by its name's
55
75
  # start (a shell's is -c only: its -e and -x are flags); others: -c, -e.
56
76
  CODE_FLAGS = { "python" => %w[-c], "pypy" => %w[-c], "ruby" => %w[-e], "irb" => %w[-e],
57
77
  "node" => %w[-e -p --eval --print], "bun" => %w[-e -p --eval --print], "perl" => %w[-e -E],
58
- "php" => %w[-r], "lua" => %w[-e], "osascript" => %w[-e], "Rscript" => %w[-e],
78
+ "php" => %w[-r -B -R -E], "lua" => %w[-e], "osascript" => %w[-e], "rscript" => %w[-e],
79
+ "jruby" => %w[-e], "raku" => %w[-e], "julia" => %w[-e],
59
80
  "tclsh" => [], "wish" => [], "deno" => [], "jshell" => [] }.freeze
60
81
  # How an interpreter's short-option cluster reads (ruby -we, perl -le,
61
82
  # python3 -Ic), by its name's start: the letters whose value is the
62
83
  # rest of the word (ruby -rset: no code), and those that take digits
63
84
  # (perl -l0e). Any other letter is a flag; the code letter's code is
64
85
  # the rest of the word, or the next word (.cluster_code_at).
86
+ # perl's -d takes only :Module (perl -de: -d -e).
65
87
  CLUSTER_VALUES = { "python" => %w[mWX], "pypy" => %w[mWX], "ruby" => %w[CEFiIrx 0W],
66
- "perl" => %w[CdDFiImMx 0l], "node" => %w[r], "bun" => %w[r], "lua" => %w[l] }.freeze
88
+ "perl" => %w[CDFiImMx 0l], "node" => %w[r], "bun" => %w[r], "lua" => %w[l] }.freeze
89
+ # More value letters, told only for where a script comes from
90
+ # (.script_source: php -dr x.php and osascript -le x run the file x, so
91
+ # SelfProtect counts it unseen; perl -d… runs .perldb from the dir it
92
+ # is in); a pair turn still reads the word after them as code too (an
93
+ # over-read: more refusals, never fewer).
94
+ SOURCE_CLUSTER_VALUES = CLUSTER_VALUES.merge("php" => %w[cdfz], "osascript" => %w[ls],
95
+ "perl" => %w[CdDFiImMx 0l]).freeze
67
96
  # A shell's options whose next word is their value, not its script.
68
97
  SHELL_VALUE_OPTIONS = %w[-o +o -O +O].freeze
98
+ # fish's long options that take a command (the next word, or after =),
99
+ # with the shortest prefix fish's getopt may take for each (--comm,
100
+ # --init): its script (--command) or one run before it (--init-command,
101
+ # and -C anywhere in a cluster, its value attached or the next word;
102
+ # not where its script comes from: .script_source).
103
+ FISH_COMMAND_OPTIONS = { "--command" => 3, "--init-command" => 4 }.freeze
69
104
  # Options whose next word is code or a command, for any program.
70
- CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
105
+ CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --init-command --eval -x].freeze
71
106
  # A shell's or interpreter's options whose next word is a value, not
72
107
  # its script (#stdin_script?).
73
108
  RUNNER_VALUE_OPTIONS = %w[-o +o -O +O -W -X -I -r --require --import --loader -C].freeze
@@ -82,7 +117,7 @@ module Samagotchi
82
117
  # key), by the program and the words that say so in its arguments.
83
118
  # Programs that load a project's env files (PairBoundary::ENV_LOADER).
84
119
  ENV_LOADERS = %w[mise direnv].freeze
85
- ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}
120
+ ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}i
86
121
  CREDENTIAL_PRINTERS = {
87
122
  "gh" => [%w[auth token]],
88
123
  "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
@@ -122,11 +157,12 @@ module Samagotchi
122
157
  LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
123
158
  /\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
124
159
  # An interpreter's script that reads the environment or detaches.
125
- SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
160
+ SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|(?i:\bprintenv\b)|%ENV\b/
126
161
  # An interpreter's script that runs git config or git -c (by its text:
127
- # the script isn't parsed).
128
- SCRIPT_GIT_CONFIG_TEXT = /\bgit\b[^\n]{0,40}?(?:\bconfig\b|["'\s]-c\b|--config-env|--template)/
129
- SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
162
+ # the script isn't parsed); git and the words in any case (GIT config),
163
+ # -c as it is (git -C dir sets nothing).
164
+ SCRIPT_GIT_CONFIG_TEXT = /(?i:\bgit\b)[^\n]{0,40}?(?:(?i:\bconfig\b)|["'\s]-c\b|(?i:--config-env|--template))/
165
+ SCRIPT_BACKGROUND_TEXT = /\b(?:(?i:nohup|setsid|disown|daemonize)|Process\.daemon|detach(?:ed)?)\b/
130
166
  # Programs that read a folder's whole tree: given home (or any folder
131
167
  # above a denied one) they reach the secrets in it.
132
168
  # A link to such a folder reaches them too (ln, link).
@@ -180,9 +216,31 @@ module Samagotchi
180
216
  # Variables whose value is a command a program runs through a shell
181
217
  # (git's diff driver, editor, pager, ssh, proxy, askpass; the editors
182
218
  # and pagers others run): read as an sh -c script, wherever the
183
- # assignment stands (a prefix, export's, env's).
219
+ # assignment stands (a prefix, export's, env's); bash's PROMPT_COMMAND.
184
220
  COMMAND_ENV = %w[GIT_EXTERNAL_DIFF GIT_EDITOR EDITOR VISUAL GIT_SEQUENCE_EDITOR GIT_SSH_COMMAND GIT_SSH
185
- GIT_PROXY_COMMAND GIT_PAGER PAGER MANPAGER GIT_ASKPASS SSH_ASKPASS].freeze
221
+ GIT_PROXY_COMMAND GIT_PAGER PAGER MANPAGER GIT_ASKPASS SSH_ASKPASS PROMPT_COMMAND].freeze
222
+ # A $(…) or backticks left as text in an assigned value (quoted: one
223
+ # that runs now is a SUBST): a shell expands it later, where the value
224
+ # is used (PS4 under bash -x, PS0/PS1, a prompt), so the value is read
225
+ # as a script too. Only a real assignment's: a prefix, a command of
226
+ # assignments, or ASSIGNERS' words (not grep 'X=$(…)').
227
+ DEFERRED_SUBST = /\$\(|`|\$\{[\s|]|\\(?:x24|044|x60|140)/
228
+ # A deferred value's escapes ($'\x24(…)') and the parts that open or
229
+ # close what it runs, cut into separators to read the commands inside.
230
+ DEFERRED_ESCAPES = /\\(?:x24|044|x60|140)/
231
+ DEFERRED_PARTS = /\$\(|\$\{(?=[\s|])|`|\)|\}/
232
+ ASSIGNERS = %w[export declare typeset readonly local env].freeze
233
+ # How many words naming git or chi the net reads (#net_hit?).
234
+ NET_GITS = 16
235
+ # Runners whose arguments are command lines they run through a shell,
236
+ # and every program #text_commands reads a command line of.
237
+ TEXT_RUNNERS = %w[hyperfine parallel watch].freeze
238
+ TEXT_TAKERS = (TEXT_RUNNERS + %w[env script npx npm rsync ssh scp sftp]).freeze
239
+ # chi's commands, for the net (#net_hit?): chi cp'd or listed isn't run.
240
+ CHI_COMMANDS = %w[sessions note broadcast context send answer share review bootstrap update models desktop
241
+ memory bundle guardrails toolkit remote home web self].freeze
242
+ # An ssh -o option that runs a command on this machine, and its value.
243
+ SSH_COMMAND_OPTION = /\A\s*(?:ProxyCommand|LocalCommand|KnownHostsCommand)\s*[=\s]\s*(.+)\z/im
186
244
  # Devices a command may name in the project's stead.
187
245
  DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
188
246
  # A word that starts with $TMPDIR or ${TMPDIR}.
@@ -241,21 +299,37 @@ module Samagotchi
241
299
  # Where the program is in +words+: past assignments, wrappers, shell
242
300
  # keywords and their options (and an option's value: ruby -I lib,
243
301
  # timeout 10).
302
+ # A wrapper's options whose value is the next word (WRAPPER_VALUES, and
303
+ # -I/-r/-C/-e/-n for any), a two-word wrapper (uv run, pnpm exec:
304
+ # SUBCOMMAND_WRAPPERS), and script's typescript file before its command.
244
305
  def self.program_at(words)
245
306
  at = 0
246
- at += 1 while at < words.size && skipped?(words, at)
307
+ wrapper = nil
308
+ file = false
309
+ while at < words.size
310
+ word = words[at]
311
+ if word.match?(/\A[A-Za-z_]\w*=/) || word.match?(/\A\d+[smhd]?\z/) then at += 1
312
+ elsif word.start_with?("-") then at += option_width(wrapper, word)
313
+ elsif wrapper == "script" && !file
314
+ at += 1
315
+ file = true
316
+ else
317
+ name = ShellLex.program_name(word)
318
+ if SUBCOMMAND_WRAPPERS.fetch(name, []).include?(words[at + 1]) then at += 2
319
+ elsif WRAPPERS.include?(name) then at += 1
320
+ else break
321
+ end
322
+ wrapper = name
323
+ end
324
+ end
247
325
  at < words.size ? at : nil
248
326
  end
249
327
 
250
- def self.skipped?(words, at)
251
- word = words[at]
252
- return true if word.match?(/\A[A-Za-z_]\w*=/) || word.start_with?("-") || word.match?(/\A\d+[smhd]?\z/)
253
- return true if WRAPPERS.include?(File.basename(word))
328
+ def self.option_width(wrapper, word)
329
+ return 1 if WRAPPER_FLAGS.fetch(wrapper, []).include?(word)
254
330
 
255
- previous = at.positive? ? words[at - 1] : nil
256
- !previous.nil? && previous.match?(/\A-[IrCen]\z/)
331
+ WRAPPER_VALUES.fetch(wrapper, []).include?(word) || word.match?(/\A-[IrCen]\z/) ? 2 : 1
257
332
  end
258
- private_class_method :skipped?
259
333
 
260
334
  # Where git's subcommand may start in its +args+ (indices): past its
261
335
  # own options (GIT_FLAG_OPTIONS, GIT_VALUE_OPTIONS and their value,
@@ -295,15 +369,19 @@ module Samagotchi
295
369
  def self.runner?(word)
296
370
  return false if word.include?("\0")
297
371
 
298
- name = File.basename(word)
372
+ name = ShellLex.program_name(word)
299
373
  SHELLS.include?(name) || interpreter?(name) || name == "eval"
300
374
  end
301
375
 
302
376
  # Whether +name+ (a word's basename) is an interpreter's (INTERPRETER).
303
377
  def self.interpreter?(name) = name.to_s.match?(INTERPRETER)
304
378
 
305
- # The options whose next word is +name+'s code (CODE_FLAGS).
306
- def self.code_flags(name)
379
+ # The options whose next word is +name+'s code (CODE_FLAGS); fish's
380
+ # --command too.
381
+ # With +source+ (.script_source) fish's --command is left out: what its
382
+ # script runs (a sourced file) SelfProtect can't see, as on a file.
383
+ def self.code_flags(name, source: false)
384
+ return source ? %w[-c] : %w[-c --command] if name == "fish"
307
385
  return %w[-c] if SHELLS.include?(name)
308
386
 
309
387
  CODE_FLAGS.find { |start, _| name.start_with?(start) }&.last || %w[-c -e]
@@ -313,29 +391,35 @@ module Samagotchi
313
391
  # one with its code after = (node --eval=…), or a short-option
314
392
  # cluster holding its code letter (.cluster_code_at: sh -ec, bash -cx,
315
393
  # ruby -we, perl -le, node -pe, python3 -Ic).
316
- def self.code_flag?(name, word)
317
- flags = code_flags(name)
318
- flags.include?(word) || !long_code(name, word).nil? || !cluster_code_at(name, word).nil?
394
+ def self.code_flag?(name, word, source: false)
395
+ flags = code_flags(name, source: source)
396
+ flags.include?(word) || !long_code(name, word, source: source).nil? ||
397
+ !cluster_code_at(name, word, source: source).nil?
319
398
  end
320
399
 
321
400
  # The code a long code option carries after = (--eval=…), nil for
322
401
  # another word.
323
- def self.long_code(name, word)
324
- flag = code_flags(name).find { |option| option.start_with?("--") && word.start_with?("#{option}=") }
402
+ def self.long_code(name, word, source: false)
403
+ flag = code_flags(name, source: source).find { |option| option.start_with?("--") && word.start_with?("#{option}=") }
325
404
  flag && word.delete_prefix("#{flag}=")
326
405
  end
327
406
 
328
407
  # Where +word+, a short-option cluster, holds +name+'s code letter
329
408
  # (a shell's c, python's c, ruby's e, node's e and p…): its index, the
330
- # letters before it read as CLUSTER_VALUES says; nil when it holds
331
- # none, or a letter before it takes the rest as its value.
332
- def self.cluster_code_at(name, word)
409
+ # letters before it read as CLUSTER_VALUES says (SOURCE_CLUSTER_VALUES
410
+ # with +source+); nil when it holds none, or a letter before it takes
411
+ # the rest as its value.
412
+ def self.cluster_code_at(name, word, source: false)
333
413
  return nil unless word.match?(/\A-[A-Za-z0-9]/)
334
414
 
335
- letters = code_flags(name).filter_map { |flag| flag[1] if flag.match?(/\A-[A-Za-z]\z/) }.join
415
+ letters = code_flags(name, source: source).filter_map { |flag| flag[1] if flag.match?(/\A-[A-Za-z]\z/) }.join
336
416
  return nil if letters.empty?
337
417
 
338
- rest, digits = SHELLS.include?(name) ? ["", ""] : cluster_values(name)
418
+ rest, digits = if SHELLS.include?(name)
419
+ ["", ""]
420
+ else
421
+ cluster_values(name, source ? SOURCE_CLUSTER_VALUES : CLUSTER_VALUES)
422
+ end
339
423
  i = 1
340
424
  while i < word.size
341
425
  char = word[i]
@@ -350,8 +434,8 @@ module Samagotchi
350
434
  nil
351
435
  end
352
436
 
353
- def self.cluster_values(name)
354
- values = CLUSTER_VALUES.find { |start, _| name.start_with?(start) }&.last || []
437
+ def self.cluster_values(name, table)
438
+ values = table.find { |start, _| name.start_with?(start) }&.last || []
355
439
  [values[0].to_s, values[1].to_s]
356
440
  end
357
441
  private_class_method :cluster_values
@@ -363,7 +447,7 @@ module Samagotchi
363
447
  # .code_flags, a long one's after =, a cluster's rest and the next
364
448
  # word: both are read when the rest isn't empty).
365
449
  def self.code_scripts(name, rest)
366
- return shell_scripts(rest) if SHELLS.include?(name)
450
+ return shell_scripts(name, rest) if SHELLS.include?(name)
367
451
 
368
452
  found = []
369
453
  rest.each_with_index do |word, i|
@@ -377,8 +461,32 @@ module Samagotchi
377
461
  found
378
462
  end
379
463
 
380
- def self.shell_scripts(rest)
381
- start = rest.index { |word| cluster_code_at("sh", word) } or return []
464
+ # A shell's scripts: the one after its -c cluster (.shell_script_after),
465
+ # and fish's commands given by an option that takes one (FISH_COMMAND_OPTIONS:
466
+ # --command S, --comm=S, --init S, -C S, -C'S', -lC S).
467
+ def self.shell_scripts(name, rest)
468
+ found = name == "fish" ? fish_commands(rest) : []
469
+ start = rest.index { |word| cluster_code_at(name, word) }
470
+ found.concat(shell_script_after(rest, start)) if start
471
+ found.compact
472
+ end
473
+ private_class_method :shell_scripts
474
+
475
+ def self.fish_commands(rest)
476
+ rest.each_with_index.filter_map do |word, i|
477
+ option, value = word.split("=", 2)
478
+ if option.start_with?("--")
479
+ long = FISH_COMMAND_OPTIONS.any? { |full, min| option.size >= min && full.start_with?(option) }
480
+ value || rest[i + 1] if long
481
+ elsif (at = word.index("C")) && word.match?(/\A-[A-Za-z]*C/)
482
+ tail = word[(at + 1)..]
483
+ tail.empty? ? rest[i + 1] : tail
484
+ end
485
+ end
486
+ end
487
+ private_class_method :fish_commands
488
+
489
+ def self.shell_script_after(rest, start)
382
490
  i = start + 1
383
491
  while i < rest.size
384
492
  word = rest[i]
@@ -389,7 +497,7 @@ module Samagotchi
389
497
  end
390
498
  []
391
499
  end
392
- private_class_method :shell_scripts
500
+ private_class_method :shell_script_after
393
501
 
394
502
  # Where a simple command's shell or interpreter (.runner?) gets its
395
503
  # script: nil without one; :inline for its code option's script
@@ -402,7 +510,7 @@ module Samagotchi
402
510
  def self.script_source(words, piped: false)
403
511
  limit = program_at(words) || (words.size - 1)
404
512
  at = (0..limit).find { |i| runner?(words[i]) } or return nil
405
- name = File.basename(words[at])
513
+ name = ShellLex.program_name(words[at])
406
514
  return :inline if name == "eval"
407
515
  return :stdin if piped
408
516
 
@@ -414,7 +522,7 @@ module Samagotchi
414
522
  rest.each do |word|
415
523
  next skip = false if skip
416
524
  return :stdin if ["-", "-s"].include?(word) || (SHELLS.include?(name) && word.match?(/\A-[a-zA-Z]*s[a-zA-Z]*\z/))
417
- return :inline if code_flag?(name, word)
525
+ return :inline if code_flag?(name, word, source: true)
418
526
  return :file if word == "-m" || word.match?(/\A-m\w/)
419
527
  # a redirection's operator alone: its target is the next word
420
528
  next skip = word.sub(ShellPaths::REDIRECT_PREFIX, "").empty? if word.match?(ShellPaths::REDIRECT_PREFIX)
@@ -473,9 +581,20 @@ module Samagotchi
473
581
  @refusal = read
474
582
  rescue StandardError
475
583
  @mise_config = true
584
+ @net = true
476
585
  @refusal = PairBoundary::UNREADABLE
477
586
  end
478
587
 
588
+ # Whether the owner approves it for what chi can't read for sure
589
+ # (PairBoundary::NET): git or chi behind a program no list knows
590
+ # (#net_hit?), or a $(…) kept in an assigned value that the shell
591
+ # expands later (#deferred_values), a script it hands on included.
592
+ # True for one it can't read.
593
+ def net_ask?
594
+ refusal
595
+ @net ||= false
596
+ end
597
+
479
598
  # Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
480
599
  # path through a link it makes now is judged before the link exists,
481
600
  # so the owner approves it (asked about; a link to a folder above a
@@ -517,7 +636,7 @@ module Samagotchi
517
636
  def outside_writes?
518
637
  return @outside_writes if defined?(@outside_writes)
519
638
 
520
- @outside_writes = env_command_outside? ||
639
+ @outside_writes = handed_command_outside? ||
521
640
  !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
522
641
  rescue StandardError
523
642
  @outside_writes = true
@@ -557,28 +676,36 @@ module Samagotchi
557
676
  return PairBoundary::CHI if chi_in_values?(words)
558
677
  return PairBoundary::VERB if at && unplain_program?(words[at])
559
678
 
560
- program = at && File.basename(words[at])
679
+ program = at && ShellLex.program_name(words[at])
561
680
  args = at ? words[(at + 1)..] : []
562
681
  return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
563
682
 
564
683
  upto = at ? words[0..at] : words
565
- return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
684
+ return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(ShellLex.program_name(w)) } || detached_session?(program, args)
566
685
  return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
567
686
 
568
687
  program, args = PairCommand.git_form(program, args)
569
688
  return PairBoundary::GIT_CONFIG if git_config_env?(words) || git_alias?(program, args)
570
689
 
571
- env_commands(words).each do |script|
572
- reason = nested(script).refusal
690
+ handed_commands(words).each do |script|
691
+ reason = nested_refusal(script)
573
692
  return reason if reason
574
693
  end
694
+ reason = deferred_refusal(words)
695
+ return reason if reason
575
696
 
576
697
  if program == "git"
577
698
  # xargs adds words of its input: a git config it runs may write
578
- xargs = words[0...at].any? { |word| File.basename(word) == "xargs" }
699
+ xargs = words[0...at].any? { |word| ShellLex.program_name(word) == "xargs" }
579
700
  reason = git_refusal(args, appended: xargs) || git_command_refusal(args)
580
701
  return reason if reason
581
702
  end
703
+ unless @read_only
704
+ net = net_hit?(words, at, program == "git")
705
+ return PairBoundary::UNREADABLE if net == :too_many
706
+
707
+ @net ||= net
708
+ end
582
709
  return PairBoundary::GIT_HOOKS if hook_installer?(words)
583
710
  return PairBoundary::OPENER if opener?(program, args)
584
711
  return PairBoundary::LISTENER if listener?(program, args)
@@ -648,11 +775,10 @@ module Samagotchi
648
775
  def scripts_refusal(words)
649
776
  limit = program_at(words) || (words.size - 1)
650
777
  at = (0..limit).find { |i| runner?(words[i]) } or return nil
651
- program = File.basename(words[at])
778
+ program = ShellLex.program_name(words[at])
652
779
  scripts(words, at).each do |script|
653
780
  reason = if SHELLS.include?(program) || program == "eval"
654
- inner = nested(script)
655
- inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
781
+ nested_refusal(script)
656
782
  elsif PairCommand.interpreter?(program)
657
783
  @mise_config = true if script.match?(MISE_CONFIG_TEXT)
658
784
  script_text_refusal(script)
@@ -665,7 +791,7 @@ module Samagotchi
665
791
  def runner?(word) = PairCommand.runner?(word)
666
792
 
667
793
  def scripts(words, at)
668
- name = File.basename(words[at])
794
+ name = ShellLex.program_name(words[at])
669
795
  found = []
670
796
  found << words[(at + 1)..].join(" ") if name == "eval"
671
797
  words.each_with_index do |word, i|
@@ -723,13 +849,29 @@ module Samagotchi
723
849
  end
724
850
  end
725
851
 
726
- # env with no command after it prints the environment.
852
+ # env with no command after it prints the environment, wherever it
853
+ # stands among the wrappers (sudo env, mise exec -- env, env -u X);
854
+ # env -S S runs S (#text_commands), but env followed by options and
855
+ # assignments only still counts (env -S 'X=1 git log' too).
727
856
  def bare_env?(words)
728
857
  assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
729
858
  at = words.index { |word| !assignment.call(word) }
730
- return false unless at && File.basename(words[at]) == "env"
731
-
732
- words[(at + 1)..].all?(&assignment)
859
+ return true if at && ShellLex.program?(words[at], "env") && words[(at + 1)..].all?(&assignment)
860
+
861
+ return false unless program_at(words).nil? && text_commands(words).empty?
862
+
863
+ # the last env, followed by its options and assignments only: no program
864
+ # (a wrapper that is a program of its own counts: env X=1 ruby -e …)
865
+ at = words.rindex { |word| ShellLex.program?(word, "env") } or return false
866
+ i = at + 1
867
+ while i < words.size
868
+ word = words[i]
869
+ if word.match?(/\A[A-Za-z_]\w*=/) then i += 1
870
+ elsif word.start_with?("-") then i += PairCommand.option_width("env", word)
871
+ else return false
872
+ end
873
+ end
874
+ true
733
875
  end
734
876
 
735
877
  # Why a git command is refused: a config write (anything but a read
@@ -818,7 +960,7 @@ module Samagotchi
818
960
  # own command would be.
819
961
  def git_command_refusal(args)
820
962
  git_commands(args).each do |script|
821
- reason = nested(script).refusal
963
+ reason = nested_refusal(script)
822
964
  return reason if reason
823
965
  end
824
966
  nil
@@ -827,7 +969,7 @@ module Samagotchi
827
969
  # alias g=git (or a function's body naming git), then `g config …`:
828
970
  # git under another name; an alias naming git is refused.
829
971
  def git_alias?(program, args)
830
- program == "alias" && args.any? { |arg| arg.match?(%r{=['"]?(?:\S*/)?git(?:\s|['"]?\z)}) }
972
+ program == "alias" && args.any? { |arg| arg.match?(%r{=['"]?(?:\S*/)?git(?:\s|['"]?\z)}i) }
831
973
  end
832
974
 
833
975
  # An assignment (a prefix, export's, env's) of a variable that hands
@@ -846,11 +988,177 @@ module Samagotchi
846
988
  end
847
989
  end
848
990
 
849
- # Whether a command an assignment hands a program (#env_commands)
850
- # may write outside, read as a command of its own.
851
- def env_command_outside?
991
+ # The values a real assignment gives (a prefix, a command of
992
+ # assignments, ASSIGNERS' words) that keep a $(…), ${ …; } or
993
+ # backticks as text (DEFERRED_SUBST, $'\x24(…)' too): the shell expands
994
+ # them later (PS4 under bash -x, PS0/PS1, a prompt).
995
+ def deferred_values(words)
996
+ at = program_at(words)
997
+ assigns = at.nil? || ASSIGNERS.include?(ShellLex.program_name(words[at]))
998
+ words.each_with_index.filter_map do |word, i|
999
+ name, value = word.match(/\A([A-Za-z_]\w*)=(.+)\z/m)&.captures
1000
+ value if name && !COMMAND_ENV.include?(name) && value.match?(DEFERRED_SUBST) && (assigns || i < at)
1001
+ end
1002
+ end
1003
+
1004
+ # A deferred value (#deferred_values) read as the commands its $(…)
1005
+ # would run (DEFERRED_PARTS cut into separators): refused for what that
1006
+ # read finds (git config, chi, a secret…), else the owner approves it
1007
+ # (#net_ask?): a value the shell expands later is no command chi can
1008
+ # read for sure.
1009
+ def deferred_refusal(words)
1010
+ deferred_values(words).each do |value|
1011
+ @net = true
1012
+ script = value.sub(/\A\$(?=\\)/, "").gsub(DEFERRED_ESCAPES) { |escape| escape.end_with?("4") ? "$" : "`" }
1013
+ reason = nested_refusal(script.gsub(DEFERRED_PARTS, " ; "))
1014
+ return reason if reason && ![PairBoundary::UNREADABLE, PairBoundary::VERB].include?(reason)
1015
+ end
1016
+ nil
1017
+ end
1018
+
1019
+ # A nested command's refusal (#nested), what it asks (#net_ask?,
1020
+ # #mise_config_write?) carried to this one.
1021
+ def nested_refusal(script)
1022
+ inner = nested(script)
1023
+ inner.refusal.tap do
1024
+ @mise_config = true if inner.mise_config_write?
1025
+ @net = true if inner.net_ask?
1026
+ end
1027
+ end
1028
+
1029
+ # The commands a simple command hands on as text, read as commands of
1030
+ # their own: its assignments' (#env_commands) and the wrappers' and
1031
+ # runners' that take a command line (#text_commands).
1032
+ def handed_commands(words) = env_commands(words) + text_commands(words)
1033
+
1034
+ # The command lines the wrappers and the program take as text: env -S
1035
+ # S, script -c S, ssh's (scp's, sftp's) -o ProxyCommand=S (and
1036
+ # LocalCommand, KnownHostsCommand: run here), and the runners that run
1037
+ # their arguments as command lines (TEXT_RUNNERS: hyperfine, parallel,
1038
+ # watch).
1039
+ def text_commands(words)
1040
+ upto = program_at(words) || (words.size - 1)
1041
+ (0..upto).flat_map do |i|
1042
+ next [] if words[i].include?("\0")
1043
+
1044
+ name = ShellLex.program_name(words[i])
1045
+ next [] unless TEXT_TAKERS.include?(name)
1046
+
1047
+ rest = words[(i + 1)..]
1048
+ case name
1049
+ when "env" then option_values(rest, %w[-S --split-string], cluster: "S")
1050
+ when "script" then option_values(rest, %w[-c --command])
1051
+ when "npx" then option_values(rest, %w[-c --call])
1052
+ when "npm" then %w[exec x].include?(rest.first) ? option_values(rest.drop(1), %w[-c --call]) : []
1053
+ when "rsync" then rsync_commands(rest)
1054
+ when "ssh", "scp", "sftp" then ssh_commands(rest)
1055
+ else text_runner_commands(name, rest)
1056
+ end
1057
+ end
1058
+ end
1059
+
1060
+ # The values of +options+ among the options at +rest+'s start (the next
1061
+ # word, after = for a long one, the rest of a -S… word for +cluster+).
1062
+ def option_values(rest, options, cluster: nil)
1063
+ found = []
1064
+ rest.each_with_index do |word, i|
1065
+ break unless word.start_with?("-")
1066
+
1067
+ option, value = word.split("=", 2)
1068
+ if options.include?(word) then found << rest[i + 1]
1069
+ elsif option.start_with?("--") && options.include?(option) then found << value
1070
+ elsif cluster && !word.start_with?("--") && (at = word.index(cluster, 1))
1071
+ tail = word[(at + 1)..]
1072
+ found << (tail.empty? ? rest[i + 1] : tail)
1073
+ end
1074
+ end
1075
+ found.compact
1076
+ end
1077
+
1078
+ # rsync's remote shell (-e S, a cluster ending in it -avze S, --rsh=S):
1079
+ # it runs here.
1080
+ def rsync_commands(rest)
1081
+ rest.each_with_index.filter_map do |word, i|
1082
+ if word.start_with?("--rsh=") then word.delete_prefix("--rsh=")
1083
+ elsif word == "--rsh" then rest[i + 1]
1084
+ elsif (cluster = word[/\A-[A-Za-z]*?e(.*)\z/m, 1]) && !word.start_with?("--")
1085
+ cluster.empty? ? rest[i + 1] : cluster
1086
+ end
1087
+ end
1088
+ end
1089
+
1090
+ # ssh's -o options that run a command on this machine.
1091
+ def ssh_commands(rest)
1092
+ rest.each_with_index.filter_map do |word, i|
1093
+ option = if word == "-o" then rest[i + 1].to_s
1094
+ elsif word.start_with?("-o") then word[2..]
1095
+ end
1096
+ option.to_s[SSH_COMMAND_OPTION, 1]
1097
+ end
1098
+ end
1099
+
1100
+ # hyperfine's arguments, each a command line (its options' values
1101
+ # too: harmless as commands); parallel's and watch's words before
1102
+ # parallel's ::: as one command line.
1103
+ def text_runner_commands(name, rest)
1104
+ words = rest.take_while { |word| !word.start_with?(":::") }.reject { |word| word.start_with?("-") }
1105
+ return words if name == "hyperfine"
1106
+
1107
+ words.empty? ? [] : [words.join(" ")]
1108
+ end
1109
+
1110
+ # A fail-closed net for a runner no table knows (a wrapper's option
1111
+ # read wrong, a package runner): any word but the program's (a
1112
+ # wrapper's or an option's value too) that names git is read as git
1113
+ # from there, one naming chi with a chi command after it (CHI_COMMANDS)
1114
+ # counts, and a command line starting with git or chi (the program
1115
+ # word too) is read as a command. A hit asks the owner (#net_ask?),
1116
+ # never refuses: chi can't tell such a word runs. A git command
1117
+ # (+git+) is read as git; only the words before it are netted.
1118
+ # More than NET_GITS such words is :too_many (UNREADABLE): each is
1119
+ # read through the rest of the words.
1120
+ def net_hit?(words, at, git)
1121
+ gits = 0
1122
+ last = git ? at - 1 : words.size - 1
1123
+ (0..last).any? do |i|
1124
+ word = words[i]
1125
+ next false if word.include?("\0")
1126
+
1127
+ if word.match?(/\s/)
1128
+ head = word.strip.split(/\s+/).first.to_s
1129
+ next false unless ShellLex.program?(head, "git") || ShellLex.program?(head, "chi")
1130
+ return :too_many if (gits += 1) > NET_GITS
1131
+
1132
+ next !nested_refusal(word).nil?
1133
+ end
1134
+ next false if i == at
1135
+
1136
+ program, = PairCommand.git_form(ShellLex.program_name(word), [])
1137
+ if program == "git"
1138
+ return :too_many if (gits += 1) > NET_GITS
1139
+
1140
+ _, rest = PairCommand.git_form(ShellLex.program_name(word), words[(i + 1)..])
1141
+ !(git_refusal(rest) || git_command_refusal(rest)).nil?
1142
+ else
1143
+ program == "chi" && chi_call?(words[i + 1])
1144
+ end
1145
+ end
1146
+ end
1147
+
1148
+ # Whether the word after a chi word makes a chi call: a chi command
1149
+ # (CHI_COMMANDS) or an option but --version/--help (chi -p "…").
1150
+ def chi_call?(first)
1151
+ return false if first.nil?
1152
+ return !CHI_ALLOWED.flatten.include?(first) if first.start_with?("-")
1153
+
1154
+ CHI_COMMANDS.include?(first)
1155
+ end
1156
+
1157
+ # Whether a command a simple command hands on (#handed_commands) may
1158
+ # write outside, read as a command of its own.
1159
+ def handed_command_outside?
852
1160
  ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
853
- words.is_a?(Array) && env_commands(words).any? { |script| nested(script).outside_writes? }
1161
+ words.is_a?(Array) && handed_commands(words).any? { |script| nested(script).outside_writes? }
854
1162
  end
855
1163
  end
856
1164
 
@@ -859,7 +1167,7 @@ module Samagotchi
859
1167
  def hook_installer?(words)
860
1168
  at = program_at(words) or return false
861
1169
  (0..at).any? do |i|
862
- name = File.basename(words[i])
1170
+ name = ShellLex.program_name(words[i])
863
1171
  next false unless HOOK_INSTALLERS.key?(name)
864
1172
 
865
1173
  verbs = HOOK_INSTALLERS[name]
@@ -872,7 +1180,7 @@ module Samagotchi
872
1180
  # denied dir; a $var word whose fixed part does; a sweeper given a
873
1181
  # folder above a denied one; SECRET_TEXT for a word it can't read.
874
1182
  def paths_refusal(words, resolved)
875
- program_word = (at = program_at(words)) && File.basename(words[at])
1183
+ program_word = (at = program_at(words)) && ShellLex.program_name(words[at])
876
1184
  resolved.each do |word, path|
877
1185
  return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
878
1186
 
@@ -930,13 +1238,13 @@ module Samagotchi
930
1238
 
931
1239
  def link_command?(words)
932
1240
  at = program_at(words)
933
- program = at && File.basename(words[at])
1241
+ program = at && ShellLex.program_name(words[at])
934
1242
  return true if LINKERS.include?(program)
935
1243
  return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
936
- return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
1244
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(ShellLex.program_name(word)) }
937
1245
 
938
1246
  runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
939
- name = File.basename(words[runner])
1247
+ name = ShellLex.program_name(words[runner])
940
1248
  scripts(words, runner).any? do |script|
941
1249
  SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
942
1250
  end
@@ -944,11 +1252,12 @@ module Samagotchi
944
1252
 
945
1253
  def env_loader_command?(words)
946
1254
  at = program_at(words)
947
- return true if at && ENV_LOADERS.include?(File.basename(words[at]))
948
- return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(File.basename(word)) }
1255
+ # wherever it stands among the wrappers too (mise exec -- env)
1256
+ return true if words[0..at].any? { |word| ENV_LOADERS.any? { |name| ShellLex.program?(word, name) } }
1257
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(ShellLex.program_name(word)) }
949
1258
 
950
1259
  runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
951
- name = File.basename(words[runner])
1260
+ name = ShellLex.program_name(words[runner])
952
1261
  scripts(words, runner).any? do |script|
953
1262
  SHELLS.include?(name) || name == "eval" ? nested(script).loads_env? : script.match?(ENV_LOADER_TEXT)
954
1263
  end
@@ -999,7 +1308,7 @@ module Samagotchi
999
1308
  program = words[i]
1000
1309
  args = words[(i + 1)..]
1001
1310
  local = project_program?(walk, program)
1002
- if PairCommand.interpreter?(File.basename(program)) && (module_args = module_run(args))
1311
+ if PairCommand.interpreter?(ShellLex.program_name(program)) && (module_args = module_run(args))
1003
1312
  program, args = module_args
1004
1313
  end
1005
1314
  ImplicitWriters.outside?(program, args, local: local, assigned: assigned)
@@ -1020,7 +1329,7 @@ module Samagotchi
1020
1329
  # (.git_commands): where it writes isn't placed, so it asks.
1021
1330
  def git_hands_command?(words)
1022
1331
  at = program_at(words) or return false
1023
- program, args = PairCommand.git_form(File.basename(words[at]), words[(at + 1)..])
1332
+ program, args = PairCommand.git_form(ShellLex.program_name(words[at]), words[(at + 1)..])
1024
1333
  program == "git" && !git_commands(args).empty?
1025
1334
  end
1026
1335
 
@@ -1043,10 +1352,10 @@ module Samagotchi
1043
1352
  def words_inside?(words, walk)
1044
1353
  at = program_at(words)
1045
1354
  # xargs runs its program on paths it reads from its input
1046
- return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
1355
+ return false if at && words[0...at].any? { |word| ShellLex.program_name(word) == "xargs" }
1047
1356
 
1048
1357
  words.each_with_index.all? do |word, i|
1049
- next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
1358
+ next true if at && (i == at || (i < at && WRAPPERS.include?(ShellLex.program_name(word))))
1050
1359
  next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
1051
1360
 
1052
1361
  word_inside?(walk, word)
@@ -1090,7 +1399,7 @@ module Samagotchi
1090
1399
  def scripts_inside?(words)
1091
1400
  limit = program_at(words) || (words.size - 1)
1092
1401
  at = (0..limit).find { |i| runner?(words[i]) } or return true
1093
- program = File.basename(words[at])
1402
+ program = ShellLex.program_name(words[at])
1094
1403
  scripts(words, at).all? do |script|
1095
1404
  if SHELLS.include?(program) || program == "eval"
1096
1405
  !nested(script).outside_writes?