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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +43 -38
- data/docs/configuration.md +11 -9
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/web-frontend.md +263 -0
- data/docs/internals/working-set.md +2 -2
- data/docs/sessions.md +5 -3
- data/docs/testing.md +36 -6
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/engine.rb +69 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
- data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
- data/lib/samagotchi/guardrails/pair_command.rb +387 -78
- data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
- data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +13 -15
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -18
- data/lib/samagotchi/web/message_display.rb +33 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +100 -31
- data/lib/samagotchi/web/public/app.js +451 -367
- data/lib/samagotchi/web/public/bubbles.js +30 -10
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/chip_popover.js +2 -2
- data/lib/samagotchi/web/public/composer.css +8 -0
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +10 -6
- data/lib/samagotchi/web/public/index.html +105 -53
- data/lib/samagotchi/web/public/info_bar.js +11 -12
- data/lib/samagotchi/web/public/info_bar_model.js +8 -10
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/level_chip.js +33 -95
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +378 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +9 -5
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/settings_chip.js +212 -0
- data/lib/samagotchi/web/public/settings_panel.js +121 -0
- data/lib/samagotchi/web/public/shell_command.js +26 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_settings.js +25 -88
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/thinking_chip.js +7 -45
- data/lib/samagotchi/web/public/time_gaps.js +5 -2
- data/lib/samagotchi/web/public/timing.js +15 -5
- data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
- data/lib/samagotchi/web/public/turn_events.js +3 -3
- data/lib/samagotchi/web/public/turn_html.js +20 -16
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +6 -3
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +15 -3
- 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
|
|
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
|
-
|
|
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|
|
|
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], "
|
|
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[
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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.
|
|
251
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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? ||
|
|
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
|
|
331
|
-
# none, or a letter before it takes
|
|
332
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
381
|
-
|
|
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 :
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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?(
|
|
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
|
-
|
|
572
|
-
reason =
|
|
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|
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
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 =
|
|
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
|
-
#
|
|
850
|
-
#
|
|
851
|
-
|
|
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) &&
|
|
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 =
|
|
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)) &&
|
|
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 &&
|
|
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?(
|
|
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 =
|
|
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
|
-
|
|
948
|
-
return true if words.
|
|
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 =
|
|
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?(
|
|
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(
|
|
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|
|
|
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?(
|
|
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 =
|
|
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?
|