samagotchi 0.133.0 → 0.134.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 +68 -1
- data/docs/cli.md +11 -0
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +102 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +6 -5
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -2
- data/docs/testing.md +7 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +13 -0
- data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
- data/lib/samagotchi/guardrails/pair_command.rb +493 -60
- data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
- data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/system_one/self_protect.rb +58 -2
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +18 -1
- data/lib/samagotchi/web/public/cards_place.js +5 -2
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +3 -1
- data/lib/samagotchi/web/public/history_reveal.js +23 -0
- data/lib/samagotchi/web/public/index.html +44 -28
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/public/turn_html.js +12 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/turn_view.js +21 -11
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- metadata +5 -1
|
@@ -4,11 +4,16 @@ require_relative "outside"
|
|
|
4
4
|
require_relative "path_resolve"
|
|
5
5
|
require_relative "read_only_shell"
|
|
6
6
|
require_relative "review_paths"
|
|
7
|
+
require_relative "shell_git_dirs"
|
|
7
8
|
require_relative "shell_paths"
|
|
8
9
|
require_relative "shell_lex"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Guardrails
|
|
13
|
+
# Read only by a pair turn's outside-write check: kept off the load
|
|
14
|
+
# path of everything that requires PairCommand (the testing kit's budget).
|
|
15
|
+
autoload :ImplicitWriters, File.expand_path("implicit_writers", __dir__)
|
|
16
|
+
|
|
12
17
|
# One shell command of a participant's pair turn, read the way
|
|
13
18
|
# PairBoundary needs it (the refusal words are PairBoundary's):
|
|
14
19
|
# - #refusal: why it may not run, nil when it may: chi, something left
|
|
@@ -41,10 +46,33 @@ module Samagotchi
|
|
|
41
46
|
# chi named in a script, a value or an argument.
|
|
42
47
|
CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}
|
|
43
48
|
SHELLS = %w[sh bash zsh dash fish ksh].freeze
|
|
44
|
-
# Other interpreters a script can be handed to (-e/-c, a heredoc)
|
|
45
|
-
|
|
49
|
+
# Other interpreters a script can be handed to (-e/-c, a heredoc), by
|
|
50
|
+
# name in any version's spelling (python3.12, pypy3, ruby3.3, perl5.36).
|
|
51
|
+
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
|
|
54
|
+
# The options whose next word is an interpreter's code, by its name's
|
|
55
|
+
# start (a shell's is -c only: its -e and -x are flags); others: -c, -e.
|
|
56
|
+
CODE_FLAGS = { "python" => %w[-c], "pypy" => %w[-c], "ruby" => %w[-e], "irb" => %w[-e],
|
|
57
|
+
"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],
|
|
59
|
+
"tclsh" => [], "wish" => [], "deno" => [], "jshell" => [] }.freeze
|
|
60
|
+
# How an interpreter's short-option cluster reads (ruby -we, perl -le,
|
|
61
|
+
# python3 -Ic), by its name's start: the letters whose value is the
|
|
62
|
+
# rest of the word (ruby -rset: no code), and those that take digits
|
|
63
|
+
# (perl -l0e). Any other letter is a flag; the code letter's code is
|
|
64
|
+
# the rest of the word, or the next word (.cluster_code_at).
|
|
65
|
+
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
|
|
67
|
+
# A shell's options whose next word is their value, not its script.
|
|
68
|
+
SHELL_VALUE_OPTIONS = %w[-o +o -O +O].freeze
|
|
46
69
|
# Options whose next word is code or a command, for any program.
|
|
47
70
|
CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
|
|
71
|
+
# A shell's or interpreter's options whose next word is a value, not
|
|
72
|
+
# its script (#stdin_script?).
|
|
73
|
+
RUNNER_VALUE_OPTIONS = %w[-o +o -O +O -W -X -I -r --require --import --loader -C].freeze
|
|
74
|
+
# A redirection of stdin (< file, 0<file), not a heredoc's <<.
|
|
75
|
+
STDIN_REDIRECT = /\A0?<(?!<)/
|
|
48
76
|
# Programs that detach what they start, or run it later (cron, at).
|
|
49
77
|
DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
|
|
50
78
|
# Programs that print the environment or a keychain's secrets, whatever
|
|
@@ -95,6 +123,9 @@ module Samagotchi
|
|
|
95
123
|
/\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
|
|
96
124
|
# An interpreter's script that reads the environment or detaches.
|
|
97
125
|
SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
|
|
126
|
+
# 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)/
|
|
98
129
|
SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
|
|
99
130
|
# Programs that read a folder's whole tree: given home (or any folder
|
|
100
131
|
# above a denied one) they reach the secrets in it.
|
|
@@ -105,11 +136,53 @@ module Samagotchi
|
|
|
105
136
|
CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
|
|
106
137
|
# An interpreter's script that makes a link.
|
|
107
138
|
SCRIPT_LINK_TEXT = /symlink|\blink\(|\bln\s|\bmklink\b|FileUtils\.ln|hardlink/i
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
139
|
+
# A pair turn writes no git config at all (any key may run a command
|
|
140
|
+
# later in the owner's name: hooks, fsmonitor, a driver, a helper, an
|
|
141
|
+
# include): only reads, parsed strictly. git config's read actions,
|
|
142
|
+
# the flags and value options a read may carry (exact spellings: git
|
|
143
|
+
# takes a long option's unique prefix too, and such a word counts as
|
|
144
|
+
# a write here), and its read subcommands.
|
|
145
|
+
GIT_CONFIG_READ_ACTIONS = %w[--get --get-all --get-regexp --get-urlmatch --get-color --get-colorbool -l
|
|
146
|
+
--list].freeze
|
|
147
|
+
GIT_CONFIG_READ_FLAGS = %w[--show-origin --show-scope --name-only -z --null --includes --no-includes --local
|
|
148
|
+
--worktree --all --regexp --fixed-value --bool --int --bool-or-int --path
|
|
149
|
+
--expiry-date --no-type].freeze
|
|
150
|
+
GIT_CONFIG_READ_VALUES = %w[-f --file --blob --type --default --value --url].freeze
|
|
151
|
+
GIT_CONFIG_READS = %w[get list].freeze
|
|
152
|
+
GIT_CONFIG_WRITES = %w[set unset rename-section remove-section edit].freeze
|
|
153
|
+
# How many words a read action takes after it (a key, a value pattern…).
|
|
154
|
+
GIT_CONFIG_READ_ARITY = { "--get-urlmatch" => 2..2, "-l" => 0..0, "--list" => 0..0, "get" => 1..1,
|
|
155
|
+
"list" => 0..0 }.freeze
|
|
156
|
+
# A clone's, an init's or a submodule's -c/--config (the new repository's
|
|
157
|
+
# config) or --template (its hooks), in any prefix git takes.
|
|
158
|
+
GIT_SEEDING = %w[clone init submodule].freeze
|
|
159
|
+
GIT_SEED_CONFIG = /\A(?:-c.*|--c(?:o(?:n(?:f(?:i(?:g)?)?)?)?)?(?:=.*)?)\z/
|
|
160
|
+
GIT_SEED_TEMPLATE = /\A--t(?:e(?:m(?:p(?:l(?:a(?:t(?:e)?)?)?)?)?)?)?(?:=.*)?\z/
|
|
161
|
+
# git's options whose value is a command it runs now (filter-branch's
|
|
162
|
+
# filters, rebase --exec, difftool --extcmd, --upload-pack, …), and the
|
|
163
|
+
# subcommands whose -x / -u is one.
|
|
164
|
+
GIT_COMMAND_OPTIONS = %w[--exec --upload-pack --receive-pack --extcmd --tree-filter --index-filter --msg-filter
|
|
165
|
+
--env-filter --commit-filter --parent-filter --tag-name-filter].freeze
|
|
166
|
+
GIT_COMMAND_SHORT = %w[rebase difftool clone fetch ls-remote pull].freeze
|
|
167
|
+
# Programs that install git hooks (or set core.hooksPath), by the
|
|
168
|
+
# arguments that do it (nil: any).
|
|
169
|
+
HOOK_INSTALLERS = { "pre-commit" => %w[install], "husky" => nil, "lefthook" => %w[install add],
|
|
170
|
+
"simple-git-hooks" => nil, "overcommit" => %w[--install], "git-hooks" => %w[install] }.freeze
|
|
171
|
+
# git's own options before its subcommand: the flags, and those whose
|
|
172
|
+
# value may be the next word. Any other option (one git knows and this
|
|
173
|
+
# list doesn't: --exec-path alone, a newer one) may or may not take
|
|
174
|
+
# the next word, and both readings are judged (.git_command_starts):
|
|
175
|
+
# an unknown option never hides the config, -c or template after it.
|
|
176
|
+
GIT_FLAG_OPTIONS = %w[-p --paginate -P --no-pager --no-replace-objects --no-lazy-fetch --no-optional-locks
|
|
177
|
+
--no-advice --bare --literal-pathspecs --glob-pathspecs --noglob-pathspecs
|
|
178
|
+
--icase-pathspecs -v --version -h --help --html-path --man-path --info-path].freeze
|
|
179
|
+
GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --attr-source --shallow-file].freeze
|
|
180
|
+
# Variables whose value is a command a program runs through a shell
|
|
181
|
+
# (git's diff driver, editor, pager, ssh, proxy, askpass; the editors
|
|
182
|
+
# and pagers others run): read as an sh -c script, wherever the
|
|
183
|
+
# assignment stands (a prefix, export's, env's).
|
|
184
|
+
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
|
|
113
186
|
# Devices a command may name in the project's stead.
|
|
114
187
|
DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
|
|
115
188
|
# A word that starts with $TMPDIR or ${TMPDIR}.
|
|
@@ -143,11 +216,13 @@ module Samagotchi
|
|
|
143
216
|
end
|
|
144
217
|
|
|
145
218
|
# mise's config files (mise.toml, .mise.toml, mise.<env>.toml, .config/mise.toml,
|
|
146
|
-
# anything under .config/mise/, mise/ or .mise/ config*.toml
|
|
147
|
-
# their [env] at the next worker
|
|
219
|
+
# anything under .config/mise/, mise/ or .mise/ config*.toml, and the
|
|
220
|
+
# .tool-versions it reads too): mise runs their [env] at the next worker
|
|
221
|
+
# start (ProjectEnv), and installs the tools they pin.
|
|
148
222
|
MISE_CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
|
|
149
|
-
\.?mise/config[^/]*\.toml)\z}x
|
|
150
|
-
MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml
|
|
223
|
+
\.?mise/config[^/]*\.toml|\.tool-versions)\z}x
|
|
224
|
+
MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml|
|
|
225
|
+
\.tool-versions(?![\w.-]))}x
|
|
151
226
|
|
|
152
227
|
# Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
|
|
153
228
|
# given or as it resolves.
|
|
@@ -163,6 +238,193 @@ module Samagotchi
|
|
|
163
238
|
(path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
|
|
164
239
|
end
|
|
165
240
|
|
|
241
|
+
# Where the program is in +words+: past assignments, wrappers, shell
|
|
242
|
+
# keywords and their options (and an option's value: ruby -I lib,
|
|
243
|
+
# timeout 10).
|
|
244
|
+
def self.program_at(words)
|
|
245
|
+
at = 0
|
|
246
|
+
at += 1 while at < words.size && skipped?(words, at)
|
|
247
|
+
at < words.size ? at : nil
|
|
248
|
+
end
|
|
249
|
+
|
|
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))
|
|
254
|
+
|
|
255
|
+
previous = at.positive? ? words[at - 1] : nil
|
|
256
|
+
!previous.nil? && previous.match?(/\A-[IrCen]\z/)
|
|
257
|
+
end
|
|
258
|
+
private_class_method :skipped?
|
|
259
|
+
|
|
260
|
+
# Where git's subcommand may start in its +args+ (indices): past its
|
|
261
|
+
# own options (GIT_FLAG_OPTIONS, GIT_VALUE_OPTIONS and their value,
|
|
262
|
+
# an option=value), every reading of an option it doesn't know (with
|
|
263
|
+
# its next word as a value and without). :config_option among them
|
|
264
|
+
# when one of those options is a -c or --config-env.
|
|
265
|
+
def self.git_command_starts(args)
|
|
266
|
+
starts = []
|
|
267
|
+
todo = [0]
|
|
268
|
+
seen = {}
|
|
269
|
+
until todo.empty?
|
|
270
|
+
i = todo.shift
|
|
271
|
+
next if seen[i]
|
|
272
|
+
|
|
273
|
+
seen[i] = true
|
|
274
|
+
word = args[i]
|
|
275
|
+
next starts << i if word.nil? || !word.start_with?("-")
|
|
276
|
+
next starts << :config_option if word.start_with?("-c", "--config-env")
|
|
277
|
+
|
|
278
|
+
if GIT_FLAG_OPTIONS.include?(word) || word.include?("=") then todo << (i + 1)
|
|
279
|
+
elsif GIT_VALUE_OPTIONS.include?(word) then todo << (i + 2)
|
|
280
|
+
else todo.push(i + 1, i + 2)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
starts
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A git builtin by its dashed name (git-config, …/git-core/git-submodule,
|
|
287
|
+
# a +program+ word's basename) as git runs it: ["git", [sub, *args]];
|
|
288
|
+
# any other program as it is.
|
|
289
|
+
def self.git_form(program, args)
|
|
290
|
+
sub = program.to_s[/\Agit-([a-z][\w-]*)\z/, 1]
|
|
291
|
+
sub ? ["git", [sub, *args]] : [program, args]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A shell, another interpreter or eval: what runs a script it is given.
|
|
295
|
+
def self.runner?(word)
|
|
296
|
+
return false if word.include?("\0")
|
|
297
|
+
|
|
298
|
+
name = File.basename(word)
|
|
299
|
+
SHELLS.include?(name) || interpreter?(name) || name == "eval"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Whether +name+ (a word's basename) is an interpreter's (INTERPRETER).
|
|
303
|
+
def self.interpreter?(name) = name.to_s.match?(INTERPRETER)
|
|
304
|
+
|
|
305
|
+
# The options whose next word is +name+'s code (CODE_FLAGS).
|
|
306
|
+
def self.code_flags(name)
|
|
307
|
+
return %w[-c] if SHELLS.include?(name)
|
|
308
|
+
|
|
309
|
+
CODE_FLAGS.find { |start, _| name.start_with?(start) }&.last || %w[-c -e]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Whether +word+ is +name+'s code option: one of .code_flags, a long
|
|
313
|
+
# one with its code after = (node --eval=…), or a short-option
|
|
314
|
+
# cluster holding its code letter (.cluster_code_at: sh -ec, bash -cx,
|
|
315
|
+
# 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?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The code a long code option carries after = (--eval=…), nil for
|
|
322
|
+
# another word.
|
|
323
|
+
def self.long_code(name, word)
|
|
324
|
+
flag = code_flags(name).find { |option| option.start_with?("--") && word.start_with?("#{option}=") }
|
|
325
|
+
flag && word.delete_prefix("#{flag}=")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Where +word+, a short-option cluster, holds +name+'s code letter
|
|
329
|
+
# (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)
|
|
333
|
+
return nil unless word.match?(/\A-[A-Za-z0-9]/)
|
|
334
|
+
|
|
335
|
+
letters = code_flags(name).filter_map { |flag| flag[1] if flag.match?(/\A-[A-Za-z]\z/) }.join
|
|
336
|
+
return nil if letters.empty?
|
|
337
|
+
|
|
338
|
+
rest, digits = SHELLS.include?(name) ? ["", ""] : cluster_values(name)
|
|
339
|
+
i = 1
|
|
340
|
+
while i < word.size
|
|
341
|
+
char = word[i]
|
|
342
|
+
return i if letters.include?(char)
|
|
343
|
+
return nil if rest.include?(char)
|
|
344
|
+
|
|
345
|
+
i += 1
|
|
346
|
+
if digits.include?(char) then i += 1 while word[i]&.match?(/\d/)
|
|
347
|
+
elsif !char.match?(/[A-Za-z]/) then return nil
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def self.cluster_values(name)
|
|
354
|
+
values = CLUSTER_VALUES.find { |start, _| name.start_with?(start) }&.last || []
|
|
355
|
+
[values[0].to_s, values[1].to_s]
|
|
356
|
+
end
|
|
357
|
+
private_class_method :cluster_values
|
|
358
|
+
|
|
359
|
+
# The scripts +name+ (a shell or an interpreter, not eval) is handed
|
|
360
|
+
# in +rest+, the words after it: a shell's is the first word that
|
|
361
|
+
# isn't an option after its -c cluster (sh -c -- S, bash -c -x S, its
|
|
362
|
+
# -o's value skipped); an interpreter's is each code option's (one of
|
|
363
|
+
# .code_flags, a long one's after =, a cluster's rest and the next
|
|
364
|
+
# word: both are read when the rest isn't empty).
|
|
365
|
+
def self.code_scripts(name, rest)
|
|
366
|
+
return shell_scripts(rest) if SHELLS.include?(name)
|
|
367
|
+
|
|
368
|
+
found = []
|
|
369
|
+
rest.each_with_index do |word, i|
|
|
370
|
+
next unless code_flag?(name, word)
|
|
371
|
+
|
|
372
|
+
if (code = long_code(name, word)) then found << code
|
|
373
|
+
elsif (at = cluster_code_at(name, word)) && at + 1 < word.size then found << word[(at + 1)..]
|
|
374
|
+
end
|
|
375
|
+
found << rest[i + 1] if rest[i + 1] && long_code(name, word).nil?
|
|
376
|
+
end
|
|
377
|
+
found
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def self.shell_scripts(rest)
|
|
381
|
+
start = rest.index { |word| cluster_code_at("sh", word) } or return []
|
|
382
|
+
i = start + 1
|
|
383
|
+
while i < rest.size
|
|
384
|
+
word = rest[i]
|
|
385
|
+
return rest[i + 1] ? [rest[i + 1]] : [] if word == "--"
|
|
386
|
+
return [word] unless word.start_with?("-", "+")
|
|
387
|
+
|
|
388
|
+
i += SHELL_VALUE_OPTIONS.include?(word) ? 2 : 1
|
|
389
|
+
end
|
|
390
|
+
[]
|
|
391
|
+
end
|
|
392
|
+
private_class_method :shell_scripts
|
|
393
|
+
|
|
394
|
+
# Where a simple command's shell or interpreter (.runner?) gets its
|
|
395
|
+
# script: nil without one; :inline for its code option's script
|
|
396
|
+
# (.code_flag?: a shell's -c, python's -c, node's -e/-p…), eval's
|
|
397
|
+
# words, a heredoc or here-string (#scripts reads them); :file for a
|
|
398
|
+
# script file or a module (-m) it names (not read); :stdin when it
|
|
399
|
+
# reads a pipe (+piped+, whatever its options: a -c script may read
|
|
400
|
+
# stdin too, exec(input())), a < file, -s, -, or no script at all.
|
|
401
|
+
# Other flags (a shell's -e, -x, -o x; python's -x) are flags.
|
|
402
|
+
def self.script_source(words, piped: false)
|
|
403
|
+
limit = program_at(words) || (words.size - 1)
|
|
404
|
+
at = (0..limit).find { |i| runner?(words[i]) } or return nil
|
|
405
|
+
name = File.basename(words[at])
|
|
406
|
+
return :inline if name == "eval"
|
|
407
|
+
return :stdin if piped
|
|
408
|
+
|
|
409
|
+
rest = words[(at + 1)..]
|
|
410
|
+
return :inline if rest.any? { |word| word.start_with?("<<") }
|
|
411
|
+
return :stdin if rest.any? { |word| word.match?(STDIN_REDIRECT) }
|
|
412
|
+
|
|
413
|
+
skip = false
|
|
414
|
+
rest.each do |word|
|
|
415
|
+
next skip = false if skip
|
|
416
|
+
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)
|
|
418
|
+
return :file if word == "-m" || word.match?(/\A-m\w/)
|
|
419
|
+
# a redirection's operator alone: its target is the next word
|
|
420
|
+
next skip = word.sub(ShellPaths::REDIRECT_PREFIX, "").empty? if word.match?(ShellPaths::REDIRECT_PREFIX)
|
|
421
|
+
next skip = RUNNER_VALUE_OPTIONS.include?(word) if word.start_with?("-", "+")
|
|
422
|
+
|
|
423
|
+
return :file
|
|
424
|
+
end
|
|
425
|
+
:stdin
|
|
426
|
+
end
|
|
427
|
+
|
|
166
428
|
# A command's text naming chi web's endpoint on this machine: a
|
|
167
429
|
# loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
|
|
168
430
|
# +ports+, anywhere in it (a URL's host:port, nc's host port, a
|
|
@@ -255,7 +517,8 @@ module Samagotchi
|
|
|
255
517
|
def outside_writes?
|
|
256
518
|
return @outside_writes if defined?(@outside_writes)
|
|
257
519
|
|
|
258
|
-
@outside_writes =
|
|
520
|
+
@outside_writes = env_command_outside? ||
|
|
521
|
+
!(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
|
|
259
522
|
rescue StandardError
|
|
260
523
|
@outside_writes = true
|
|
261
524
|
end
|
|
@@ -274,11 +537,14 @@ module Samagotchi
|
|
|
274
537
|
return PairBoundary::CHI_WEB if @web && code.match?(@web)
|
|
275
538
|
return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
|
|
276
539
|
|
|
277
|
-
|
|
540
|
+
read_only = ReadOnlyShell.read_only_parts(tokens)
|
|
278
541
|
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
279
542
|
ShellLex.simple_commands(tokens).each do |words|
|
|
280
543
|
next walk.step(words) if words.is_a?(Symbol)
|
|
281
544
|
|
|
545
|
+
# A path the command names counts as written (mise's config, git's
|
|
546
|
+
# hooks) unless the part of it holding this command only reads.
|
|
547
|
+
@read_only = read_only.shift
|
|
282
548
|
reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
|
|
283
549
|
return reason if reason
|
|
284
550
|
end
|
|
@@ -298,13 +564,45 @@ module Samagotchi
|
|
|
298
564
|
upto = at ? words[0..at] : words
|
|
299
565
|
return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
|
|
300
566
|
return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
|
|
301
|
-
|
|
567
|
+
|
|
568
|
+
program, args = PairCommand.git_form(program, args)
|
|
569
|
+
return PairBoundary::GIT_CONFIG if git_config_env?(words) || git_alias?(program, args)
|
|
570
|
+
|
|
571
|
+
env_commands(words).each do |script|
|
|
572
|
+
reason = nested(script).refusal
|
|
573
|
+
return reason if reason
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
if program == "git"
|
|
577
|
+
# xargs adds words of its input: a git config it runs may write
|
|
578
|
+
xargs = words[0...at].any? { |word| File.basename(word) == "xargs" }
|
|
579
|
+
reason = git_refusal(args, appended: xargs) || git_command_refusal(args)
|
|
580
|
+
return reason if reason
|
|
581
|
+
end
|
|
582
|
+
return PairBoundary::GIT_HOOKS if hook_installer?(words)
|
|
302
583
|
return PairBoundary::OPENER if opener?(program, args)
|
|
303
584
|
return PairBoundary::LISTENER if listener?(program, args)
|
|
304
585
|
|
|
586
|
+
exec_commands(args).each do |command|
|
|
587
|
+
reason = words_refusal(command)
|
|
588
|
+
return reason if reason
|
|
589
|
+
end
|
|
305
590
|
nil
|
|
306
591
|
end
|
|
307
592
|
|
|
593
|
+
# The commands find runs (-exec, -execdir, -ok, -okdir … ; or +):
|
|
594
|
+
# judged as commands of their own.
|
|
595
|
+
def exec_commands(args)
|
|
596
|
+
found = []
|
|
597
|
+
args.each_with_index do |arg, at|
|
|
598
|
+
next unless %w[-exec -execdir -ok -okdir].include?(arg)
|
|
599
|
+
|
|
600
|
+
command = args[(at + 1)..].take_while { |word| !%w[; \\; +].include?(word) }
|
|
601
|
+
found << command unless command.empty?
|
|
602
|
+
end
|
|
603
|
+
found
|
|
604
|
+
end
|
|
605
|
+
|
|
308
606
|
# open, xdg-open, a browser, gio open; osascript's open location is
|
|
309
607
|
# its script's (#script_text_refusal).
|
|
310
608
|
def opener?(program, args)
|
|
@@ -355,7 +653,7 @@ module Samagotchi
|
|
|
355
653
|
reason = if SHELLS.include?(program) || program == "eval"
|
|
356
654
|
inner = nested(script)
|
|
357
655
|
inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
|
|
358
|
-
elsif
|
|
656
|
+
elsif PairCommand.interpreter?(program)
|
|
359
657
|
@mise_config = true if script.match?(MISE_CONFIG_TEXT)
|
|
360
658
|
script_text_refusal(script)
|
|
361
659
|
end
|
|
@@ -364,20 +662,18 @@ module Samagotchi
|
|
|
364
662
|
nil
|
|
365
663
|
end
|
|
366
664
|
|
|
367
|
-
def runner?(word)
|
|
368
|
-
name = File.basename(word)
|
|
369
|
-
SHELLS.include?(name) || INTERPRETERS.include?(name) || name == "eval"
|
|
370
|
-
end
|
|
665
|
+
def runner?(word) = PairCommand.runner?(word)
|
|
371
666
|
|
|
372
667
|
def scripts(words, at)
|
|
668
|
+
name = File.basename(words[at])
|
|
373
669
|
found = []
|
|
374
|
-
found << words[(at + 1)..].join(" ") if
|
|
670
|
+
found << words[(at + 1)..].join(" ") if name == "eval"
|
|
375
671
|
words.each_with_index do |word, i|
|
|
376
672
|
next if i.zero? || word == ShellLex::HEREDOC
|
|
377
673
|
|
|
378
|
-
|
|
379
|
-
found << word if previous.start_with?("<<") || (i > at && %w[-c -e].include?(previous))
|
|
674
|
+
found << word if words[i - 1].start_with?("<<")
|
|
380
675
|
end
|
|
676
|
+
found.concat(PairCommand.code_scripts(name, words[(at + 1)..])) unless name == "eval"
|
|
381
677
|
found
|
|
382
678
|
end
|
|
383
679
|
|
|
@@ -388,27 +684,12 @@ module Samagotchi
|
|
|
388
684
|
return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
|
|
389
685
|
return PairBoundary::OPENER if script.match?(SCRIPT_OPENER_TEXT)
|
|
390
686
|
return PairBoundary::LISTENER if script.match?(SCRIPT_LISTEN_TEXT)
|
|
687
|
+
return PairBoundary::GIT_CONFIG if script.match?(SCRIPT_GIT_CONFIG_TEXT)
|
|
391
688
|
|
|
392
689
|
nil
|
|
393
690
|
end
|
|
394
691
|
|
|
395
|
-
|
|
396
|
-
# keywords and their options (and an option's value: ruby -I lib,
|
|
397
|
-
# timeout 10).
|
|
398
|
-
def program_at(words)
|
|
399
|
-
at = 0
|
|
400
|
-
at += 1 while at < words.size && skipped?(words, at)
|
|
401
|
-
at < words.size ? at : nil
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def skipped?(words, at)
|
|
405
|
-
word = words[at]
|
|
406
|
-
return true if word.match?(/\A[A-Za-z_]\w*=/) || word.start_with?("-") || word.match?(/\A\d+[smhd]?\z/)
|
|
407
|
-
return true if WRAPPERS.include?(File.basename(word))
|
|
408
|
-
|
|
409
|
-
previous = at.positive? ? words[at - 1] : nil
|
|
410
|
-
!previous.nil? && previous.match?(/\A-[IrCen]\z/)
|
|
411
|
-
end
|
|
692
|
+
def program_at(words) = PairCommand.program_at(words)
|
|
412
693
|
|
|
413
694
|
# screen -d…, tmux new -d: a session left running.
|
|
414
695
|
def detached_session?(program, args)
|
|
@@ -451,34 +732,139 @@ module Samagotchi
|
|
|
451
732
|
words[(at + 1)..].all?(&assignment)
|
|
452
733
|
end
|
|
453
734
|
|
|
454
|
-
# git
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
735
|
+
# Why a git command is refused: a config write (anything but a read
|
|
736
|
+
# .git_config_read? holds for, git -c and --config-env whatever they
|
|
737
|
+
# set, a clone's, init's or submodule's -c/--config: GIT_CONFIG) or a
|
|
738
|
+
# template (--template: its hooks, GIT_HOOKS); nil for the rest.
|
|
739
|
+
def git_refusal(args, appended: false)
|
|
740
|
+
starts = PairCommand.git_command_starts(args)
|
|
741
|
+
return PairBoundary::GIT_CONFIG if starts.include?(:config_option)
|
|
742
|
+
|
|
743
|
+
starts.each do |i|
|
|
744
|
+
reason = git_command_refusal_at(args[i], args[(i + 1)..] || [], appended: appended)
|
|
745
|
+
return reason if reason
|
|
746
|
+
end
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def git_command_refusal_at(command, rest, appended:)
|
|
751
|
+
return (!appended && git_config_read?(rest) ? nil : PairBoundary::GIT_CONFIG) if command == "config"
|
|
752
|
+
return nil unless GIT_SEEDING.include?(command)
|
|
753
|
+
return PairBoundary::GIT_CONFIG if rest.any? { |arg| arg.match?(GIT_SEED_CONFIG) }
|
|
462
754
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
755
|
+
rest.any? { |arg| arg.match?(GIT_SEED_TEMPLATE) } ? PairBoundary::GIT_HOOKS : nil
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Whether git config's arguments (+rest+) only read, every word
|
|
759
|
+
# accounted for: a read action or subcommand (GIT_CONFIG_READ_ACTIONS,
|
|
760
|
+
# GIT_CONFIG_READS) or a lone key, the flags and value options a read
|
|
761
|
+
# may carry (exact spellings), and as many words as the action takes.
|
|
762
|
+
# Anything else (an unknown or abbreviated option, a value option at
|
|
763
|
+
# the end, a word too many) is a write.
|
|
764
|
+
def git_config_read?(rest)
|
|
765
|
+
actions = []
|
|
766
|
+
positional = []
|
|
767
|
+
skip = false
|
|
768
|
+
rest.each do |arg|
|
|
769
|
+
next skip = false if skip
|
|
770
|
+
|
|
771
|
+
if arg.start_with?("-") && arg != "-"
|
|
772
|
+
if GIT_CONFIG_READ_ACTIONS.include?(arg) then actions << arg
|
|
773
|
+
elsif GIT_CONFIG_READ_VALUES.include?(arg) then skip = true
|
|
774
|
+
elsif !GIT_CONFIG_READ_FLAGS.include?(arg) && !GIT_CONFIG_READ_VALUES.include?(arg[/\A[^=]+(?==)/])
|
|
775
|
+
return false
|
|
776
|
+
end
|
|
466
777
|
else
|
|
467
|
-
|
|
778
|
+
positional << arg
|
|
468
779
|
end
|
|
469
780
|
end
|
|
470
|
-
return false
|
|
781
|
+
return false if skip
|
|
471
782
|
|
|
472
|
-
|
|
473
|
-
|
|
783
|
+
if GIT_CONFIG_READS.include?(positional.first)
|
|
784
|
+
return false unless actions.empty?
|
|
785
|
+
|
|
786
|
+
actions << positional.shift
|
|
787
|
+
end
|
|
788
|
+
return positional.size == 1 && !GIT_CONFIG_WRITES.include?(positional.first) if actions.empty?
|
|
789
|
+
return false unless actions.size == 1
|
|
474
790
|
|
|
475
|
-
|
|
476
|
-
running_setting?(key, value)
|
|
791
|
+
GIT_CONFIG_READ_ARITY.fetch(actions.first, 1..2).cover?(positional.size)
|
|
477
792
|
end
|
|
478
793
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
794
|
+
# The commands a git command runs now, handed to it as text (a
|
|
795
|
+
# submodule foreach's, a rebase --exec's, a filter's, an --upload-pack,
|
|
796
|
+
# an ext:: URL…; GIT_COMMAND_OPTIONS): read as commands of their own.
|
|
797
|
+
def git_commands(args)
|
|
798
|
+
PairCommand.git_command_starts(args).grep(Integer).flat_map do |i|
|
|
799
|
+
git_commands_at(args[i], args[(i + 1)..] || [])
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def git_commands_at(command, rest)
|
|
804
|
+
found = rest.filter_map { |arg| arg.delete_prefix("ext::") if arg.start_with?("ext::") }
|
|
805
|
+
found << rest.drop_while { |arg| arg != "foreach" }.drop(1).reject { |arg| arg.start_with?("-") }.join(" ") if
|
|
806
|
+
command == "submodule" && rest.include?("foreach")
|
|
807
|
+
found << rest.drop_while { |arg| arg != "run" }.drop(1).join(" ") if command == "bisect" && rest.include?("run")
|
|
808
|
+
rest.each_with_index do |arg, at|
|
|
809
|
+
name, value = arg.split("=", 2)
|
|
810
|
+
next unless GIT_COMMAND_OPTIONS.include?(name) || (%w[-x -u].include?(name) && GIT_COMMAND_SHORT.include?(command))
|
|
811
|
+
|
|
812
|
+
found << (value || rest[at + 1].to_s)
|
|
813
|
+
end
|
|
814
|
+
found
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# A command a git command runs now (.git_commands), refused as its
|
|
818
|
+
# own command would be.
|
|
819
|
+
def git_command_refusal(args)
|
|
820
|
+
git_commands(args).each do |script|
|
|
821
|
+
reason = nested(script).refusal
|
|
822
|
+
return reason if reason
|
|
823
|
+
end
|
|
824
|
+
nil
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# alias g=git (or a function's body naming git), then `g config …`:
|
|
828
|
+
# git under another name; an alias naming git is refused.
|
|
829
|
+
def git_alias?(program, args)
|
|
830
|
+
program == "alias" && args.any? { |arg| arg.match?(%r{=['"]?(?:\S*/)?git(?:\s|['"]?\z)}) }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# An assignment (a prefix, export's, env's) of a variable that hands
|
|
834
|
+
# git config or a template of its own (ShellGitDirs::CONFIG_ENV): what
|
|
835
|
+
# it sets may run a command, as git -c's may.
|
|
836
|
+
def git_config_env?(words)
|
|
837
|
+
words.any? { |word| word[/\A([A-Za-z_]\w*)=/, 1].to_s.match?(ShellGitDirs::CONFIG_ENV) }
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# The commands a simple command's assignments hand a program
|
|
841
|
+
# (COMMAND_ENV: GIT_EDITOR='…' git commit, export GIT_PAGER=…).
|
|
842
|
+
def env_commands(words)
|
|
843
|
+
words.filter_map do |word|
|
|
844
|
+
name, value = word.match(/\A([A-Za-z_]\w*)=(.+)\z/m)&.captures
|
|
845
|
+
value if COMMAND_ENV.include?(name)
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
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?
|
|
852
|
+
ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
|
|
853
|
+
words.is_a?(Array) && env_commands(words).any? { |script| nested(script).outside_writes? }
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# A hook installer (HOOK_INSTALLERS: pre-commit install, husky,
|
|
858
|
+
# lefthook install…), wherever it stands among the wrappers (npx husky).
|
|
859
|
+
def hook_installer?(words)
|
|
860
|
+
at = program_at(words) or return false
|
|
861
|
+
(0..at).any? do |i|
|
|
862
|
+
name = File.basename(words[i])
|
|
863
|
+
next false unless HOOK_INSTALLERS.key?(name)
|
|
864
|
+
|
|
865
|
+
verbs = HOOK_INSTALLERS[name]
|
|
866
|
+
verbs.nil? || words[(i + 1)..].intersect?(verbs)
|
|
867
|
+
end
|
|
482
868
|
end
|
|
483
869
|
|
|
484
870
|
# The secrets a simple command's words reach: a resolved path on the
|
|
@@ -591,14 +977,61 @@ module Samagotchi
|
|
|
591
977
|
return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
|
|
592
978
|
|
|
593
979
|
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
980
|
+
piped = ShellLex.piped_flags(tokens)
|
|
594
981
|
ShellLex.simple_commands(tokens).all? do |words|
|
|
595
982
|
next walk.step(words) if words.is_a?(Symbol)
|
|
596
983
|
|
|
984
|
+
stdin = PairCommand.script_source(words, piped: piped.shift) == :stdin || git_hands_command?(words)
|
|
597
985
|
walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
|
|
598
|
-
scripts_inside?(words).tap { walk.step(words) }
|
|
986
|
+
!implicit_outside?(words, walk) && !stdin && scripts_inside?(words).tap { walk.step(words) }
|
|
599
987
|
end
|
|
600
988
|
end
|
|
601
989
|
|
|
990
|
+
# A program that writes to its own place outside with no word naming
|
|
991
|
+
# it (ImplicitWriters: npm -g, gem install, brew, defaults write, …),
|
|
992
|
+
# wherever it stands among the wrappers (sudo npm, bundle config),
|
|
993
|
+
# python -m's module as the program, under the assignments before it
|
|
994
|
+
# (npm_config_global=true).
|
|
995
|
+
def implicit_outside?(words, walk)
|
|
996
|
+
at = program_at(words) or return false
|
|
997
|
+
assigned = words[0...at].filter_map { |word| word.split("=", 2) if word.match?(/\A[A-Za-z_]\w*=/) }.to_h
|
|
998
|
+
(0..at).any? do |i|
|
|
999
|
+
program = words[i]
|
|
1000
|
+
args = words[(i + 1)..]
|
|
1001
|
+
local = project_program?(walk, program)
|
|
1002
|
+
if PairCommand.interpreter?(File.basename(program)) && (module_args = module_run(args))
|
|
1003
|
+
program, args = module_args
|
|
1004
|
+
end
|
|
1005
|
+
ImplicitWriters.outside?(program, args, local: local, assigned: assigned)
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# An interpreter's -m module and the words after it ([name, args]):
|
|
1010
|
+
# -m pip, -mpip; nil without one.
|
|
1011
|
+
def module_run(args)
|
|
1012
|
+
args.each_with_index do |arg, i|
|
|
1013
|
+
return [args[i + 1], args[(i + 2)..]] if arg == "-m" && args[i + 1]
|
|
1014
|
+
return [arg.delete_prefix("-m"), args[(i + 1)..]] if arg.match?(/\A-m\w/)
|
|
1015
|
+
end
|
|
1016
|
+
nil
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Whether a git command runs a command now that it is handed as text
|
|
1020
|
+
# (.git_commands): where it writes isn't placed, so it asks.
|
|
1021
|
+
def git_hands_command?(words)
|
|
1022
|
+
at = program_at(words) or return false
|
|
1023
|
+
program, args = PairCommand.git_form(File.basename(words[at]), words[(at + 1)..])
|
|
1024
|
+
program == "git" && !git_commands(args).empty?
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Whether a program's word is a path into the project (.venv/bin/pip).
|
|
1028
|
+
def project_program?(walk, word)
|
|
1029
|
+
return false unless word.include?("/")
|
|
1030
|
+
|
|
1031
|
+
paths = walk.paths(word)
|
|
1032
|
+
!paths.empty? && paths.all? { |path| Outside.inside?(path, root: @root) }
|
|
1033
|
+
end
|
|
1034
|
+
|
|
602
1035
|
# Whether every $(…) is a message's heredoc (its body cut as data).
|
|
603
1036
|
def substs_messages_only?
|
|
604
1037
|
rest = ShellLex.code_text(@text, keep_written: false).gsub(MESSAGE_SUBST, "")
|