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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/cli.md +11 -0
  4. data/docs/configuration.md +8 -4
  5. data/docs/guardrails.md +102 -13
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/participants.md +6 -5
  8. data/docs/internals/tool-guardrails.md +2 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -2
  11. data/docs/testing.md +7 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/chi_home.rb +12 -2
  14. data/lib/samagotchi/desktop/macos.rb +19 -4
  15. data/lib/samagotchi/engine.rb +13 -0
  16. data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +493 -60
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
  20. data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
  21. data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
  22. data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
  23. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  24. data/lib/samagotchi/guardrails/targets.rb +8 -3
  25. data/lib/samagotchi/home_command.rb +22 -6
  26. data/lib/samagotchi/home_doctor.rb +36 -3
  27. data/lib/samagotchi/home_notice.rb +8 -1
  28. data/lib/samagotchi/hooks/loader.rb +5 -0
  29. data/lib/samagotchi/log_line.rb +12 -0
  30. data/lib/samagotchi/project_env.rb +3 -11
  31. data/lib/samagotchi/self_report.rb +3 -1
  32. data/lib/samagotchi/system_one/self_protect.rb +58 -2
  33. data/lib/samagotchi/testing/waiting.rb +13 -0
  34. data/lib/samagotchi/toolkit_binding.rb +16 -0
  35. data/lib/samagotchi/update_command.rb +7 -3
  36. data/lib/samagotchi/update_hint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  39. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  40. data/lib/samagotchi/web/public/app.js +18 -1
  41. data/lib/samagotchi/web/public/cards_place.js +5 -2
  42. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  43. data/lib/samagotchi/web/public/claim_link.js +4 -2
  44. data/lib/samagotchi/web/public/composer.css +3 -1
  45. data/lib/samagotchi/web/public/history_reveal.js +23 -0
  46. data/lib/samagotchi/web/public/index.html +44 -28
  47. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  48. data/lib/samagotchi/web/public/refs.js +6 -7
  49. data/lib/samagotchi/web/public/review_start.js +1 -1
  50. data/lib/samagotchi/web/public/start_page.js +4 -4
  51. data/lib/samagotchi/web/public/start_settings.js +29 -18
  52. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  53. data/lib/samagotchi/web/public/timing.js +5 -1
  54. data/lib/samagotchi/web/public/turn_html.js +12 -6
  55. data/lib/samagotchi/web/public/turn_range.js +32 -0
  56. data/lib/samagotchi/web/public/turn_view.js +21 -11
  57. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  58. data/lib/samagotchi/web/public/verify_target.js +7 -10
  59. 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
- INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
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
- # git settings that make git run a command (or show one), and git
109
- # config's scopes beyond the repository.
110
- GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
111
- GIT_WIDE_SCOPES = %w[--global --system].freeze
112
- GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
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): mise runs
147
- # their [env] at the next worker start (ProjectEnv).
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 = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
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
- @read_only = ReadOnlyShell.read_only?(@text)
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
- return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
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 INTERPRETERS.include?(program)
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 File.basename(words[at]) == "eval"
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
- previous = words[i - 1]
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
- # Where the program is in +words+: past assignments, wrappers, shell
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 config beyond the repository (--global, --system), or a setting
455
- # that runs a command (core.hooksPath, core.fsmonitor, core.sshCommand,
456
- # core.pager, core.editor, a "!" alias), set with git config or git -c.
457
- def git_runs_commands?(args)
458
- i = 0
459
- while i < args.size && args[i].start_with?("-")
460
- if args[i] == "-c"
461
- return true if running_setting?(*args[i + 1].to_s.split("=", 2))
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
- i += 2
464
- elsif GIT_VALUE_OPTIONS.include?(args[i])
465
- i += 2
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
- i += 1
778
+ positional << arg
468
779
  end
469
780
  end
470
- return false unless args[i] == "config"
781
+ return false if skip
471
782
 
472
- rest = args[(i + 1)..]
473
- return true if rest.any? { |arg| GIT_WIDE_SCOPES.include?(arg) }
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
- key, value = rest.reject { |arg| arg.start_with?("-") }
476
- running_setting?(key, value)
791
+ GIT_CONFIG_READ_ARITY.fetch(actions.first, 1..2).cover?(positional.size)
477
792
  end
478
793
 
479
- def running_setting?(key, value)
480
- key = key.to_s.downcase
481
- GIT_RUNNING_KEYS.include?(key) || (key.start_with?("alias.") && value.to_s.lstrip.start_with?("!"))
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, "")