samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -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
@@ -34,17 +39,75 @@ module Samagotchi
34
39
  class PairCommand
35
40
  # What runs a command on another's behalf, and the shell's keywords
36
41
  # before a command: skipped to find the program.
37
- WRAPPERS = %w[env exec command nohup time sudo nice xargs setsid timeout stdbuf caffeinate bundle ruby watch npx
38
- { ! 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
39
63
  # chi's own invocations a pair turn may run: they print and exit.
40
64
  CHI_ALLOWED = [%w[--version], %w[--help]].freeze
41
- # chi named in a script, a value or an argument.
42
- CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}
65
+ # chi named in a script, a value or an argument, in any case (CHI runs
66
+ # chi on a case-insensitive disk: ShellLex.program_name).
67
+ CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}i
43
68
  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
69
+ # Other interpreters a script can be handed to (-e/-c, a heredoc), by
70
+ # name in any version's spelling (python3.12, pypy3, ruby3.3, perl5.36).
71
+ INTERPRETER = /\A(?:python\d*(?:\.\d+)?|pypy\d*(?:\.\d+)?|ruby\d*(?:\.\d+)?|irb|node(?:js)?|deno|bun|
72
+ perl\d*(?:\.\d+)*|php\d*(?:\.\d+)?|lua(?:jit|\d*(?:\.\d+)?)|osascript|jshell|rscript|
73
+ tclsh\d*(?:\.\d+)?|wish|jruby|raku|julia)\z/x
74
+ # The options whose next word is an interpreter's code, by its name's
75
+ # start (a shell's is -c only: its -e and -x are flags); others: -c, -e.
76
+ CODE_FLAGS = { "python" => %w[-c], "pypy" => %w[-c], "ruby" => %w[-e], "irb" => %w[-e],
77
+ "node" => %w[-e -p --eval --print], "bun" => %w[-e -p --eval --print], "perl" => %w[-e -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],
80
+ "tclsh" => [], "wish" => [], "deno" => [], "jshell" => [] }.freeze
81
+ # How an interpreter's short-option cluster reads (ruby -we, perl -le,
82
+ # python3 -Ic), by its name's start: the letters whose value is the
83
+ # rest of the word (ruby -rset: no code), and those that take digits
84
+ # (perl -l0e). Any other letter is a flag; the code letter's code is
85
+ # the rest of the word, or the next word (.cluster_code_at).
86
+ # perl's -d takes only :Module (perl -de: -d -e).
87
+ CLUSTER_VALUES = { "python" => %w[mWX], "pypy" => %w[mWX], "ruby" => %w[CEFiIrx 0W],
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
96
+ # A shell's options whose next word is their value, not its script.
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
46
104
  # Options whose next word is code or a command, for any program.
47
- 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
106
+ # A shell's or interpreter's options whose next word is a value, not
107
+ # its script (#stdin_script?).
108
+ RUNNER_VALUE_OPTIONS = %w[-o +o -O +O -W -X -I -r --require --import --loader -C].freeze
109
+ # A redirection of stdin (< file, 0<file), not a heredoc's <<.
110
+ STDIN_REDIRECT = /\A0?<(?!<)/
48
111
  # Programs that detach what they start, or run it later (cron, at).
49
112
  DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
50
113
  # Programs that print the environment or a keychain's secrets, whatever
@@ -54,7 +117,7 @@ module Samagotchi
54
117
  # key), by the program and the words that say so in its arguments.
55
118
  # Programs that load a project's env files (PairBoundary::ENV_LOADER).
56
119
  ENV_LOADERS = %w[mise direnv].freeze
57
- ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}
120
+ ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}i
58
121
  CREDENTIAL_PRINTERS = {
59
122
  "gh" => [%w[auth token]],
60
123
  "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
@@ -94,8 +157,12 @@ module Samagotchi
94
157
  LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
95
158
  /\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
96
159
  # An interpreter's script that reads the environment or detaches.
97
- SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
98
- SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
160
+ SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|(?i:\bprintenv\b)|%ENV\b/
161
+ # An interpreter's script that runs git config or git -c (by its text:
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/
99
166
  # Programs that read a folder's whole tree: given home (or any folder
100
167
  # above a denied one) they reach the secrets in it.
101
168
  # A link to such a folder reaches them too (ln, link).
@@ -105,11 +172,75 @@ module Samagotchi
105
172
  CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
106
173
  # An interpreter's script that makes a link.
107
174
  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
175
+ # A pair turn writes no git config at all (any key may run a command
176
+ # later in the owner's name: hooks, fsmonitor, a driver, a helper, an
177
+ # include): only reads, parsed strictly. git config's read actions,
178
+ # the flags and value options a read may carry (exact spellings: git
179
+ # takes a long option's unique prefix too, and such a word counts as
180
+ # a write here), and its read subcommands.
181
+ GIT_CONFIG_READ_ACTIONS = %w[--get --get-all --get-regexp --get-urlmatch --get-color --get-colorbool -l
182
+ --list].freeze
183
+ GIT_CONFIG_READ_FLAGS = %w[--show-origin --show-scope --name-only -z --null --includes --no-includes --local
184
+ --worktree --all --regexp --fixed-value --bool --int --bool-or-int --path
185
+ --expiry-date --no-type].freeze
186
+ GIT_CONFIG_READ_VALUES = %w[-f --file --blob --type --default --value --url].freeze
187
+ GIT_CONFIG_READS = %w[get list].freeze
188
+ GIT_CONFIG_WRITES = %w[set unset rename-section remove-section edit].freeze
189
+ # How many words a read action takes after it (a key, a value pattern…).
190
+ GIT_CONFIG_READ_ARITY = { "--get-urlmatch" => 2..2, "-l" => 0..0, "--list" => 0..0, "get" => 1..1,
191
+ "list" => 0..0 }.freeze
192
+ # A clone's, an init's or a submodule's -c/--config (the new repository's
193
+ # config) or --template (its hooks), in any prefix git takes.
194
+ GIT_SEEDING = %w[clone init submodule].freeze
195
+ GIT_SEED_CONFIG = /\A(?:-c.*|--c(?:o(?:n(?:f(?:i(?:g)?)?)?)?)?(?:=.*)?)\z/
196
+ GIT_SEED_TEMPLATE = /\A--t(?:e(?:m(?:p(?:l(?:a(?:t(?:e)?)?)?)?)?)?)?(?:=.*)?\z/
197
+ # git's options whose value is a command it runs now (filter-branch's
198
+ # filters, rebase --exec, difftool --extcmd, --upload-pack, …), and the
199
+ # subcommands whose -x / -u is one.
200
+ GIT_COMMAND_OPTIONS = %w[--exec --upload-pack --receive-pack --extcmd --tree-filter --index-filter --msg-filter
201
+ --env-filter --commit-filter --parent-filter --tag-name-filter].freeze
202
+ GIT_COMMAND_SHORT = %w[rebase difftool clone fetch ls-remote pull].freeze
203
+ # Programs that install git hooks (or set core.hooksPath), by the
204
+ # arguments that do it (nil: any).
205
+ HOOK_INSTALLERS = { "pre-commit" => %w[install], "husky" => nil, "lefthook" => %w[install add],
206
+ "simple-git-hooks" => nil, "overcommit" => %w[--install], "git-hooks" => %w[install] }.freeze
207
+ # git's own options before its subcommand: the flags, and those whose
208
+ # value may be the next word. Any other option (one git knows and this
209
+ # list doesn't: --exec-path alone, a newer one) may or may not take
210
+ # the next word, and both readings are judged (.git_command_starts):
211
+ # an unknown option never hides the config, -c or template after it.
212
+ GIT_FLAG_OPTIONS = %w[-p --paginate -P --no-pager --no-replace-objects --no-lazy-fetch --no-optional-locks
213
+ --no-advice --bare --literal-pathspecs --glob-pathspecs --noglob-pathspecs
214
+ --icase-pathspecs -v --version -h --help --html-path --man-path --info-path].freeze
215
+ GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --attr-source --shallow-file].freeze
216
+ # Variables whose value is a command a program runs through a shell
217
+ # (git's diff driver, editor, pager, ssh, proxy, askpass; the editors
218
+ # and pagers others run): read as an sh -c script, wherever the
219
+ # assignment stands (a prefix, export's, env's); bash's PROMPT_COMMAND.
220
+ COMMAND_ENV = %w[GIT_EXTERNAL_DIFF GIT_EDITOR EDITOR VISUAL GIT_SEQUENCE_EDITOR GIT_SSH_COMMAND GIT_SSH
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
113
244
  # Devices a command may name in the project's stead.
114
245
  DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
115
246
  # A word that starts with $TMPDIR or ${TMPDIR}.
@@ -143,11 +274,13 @@ module Samagotchi
143
274
  end
144
275
 
145
276
  # 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).
277
+ # anything under .config/mise/, mise/ or .mise/ config*.toml, and the
278
+ # .tool-versions it reads too): mise runs their [env] at the next worker
279
+ # start (ProjectEnv), and installs the tools they pin.
148
280
  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)}
281
+ \.?mise/config[^/]*\.toml|\.tool-versions)\z}x
282
+ MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml|
283
+ \.tool-versions(?![\w.-]))}x
151
284
 
152
285
  # Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
153
286
  # given or as it resolves.
@@ -163,6 +296,243 @@ module Samagotchi
163
296
  (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
164
297
  end
165
298
 
299
+ # Where the program is in +words+: past assignments, wrappers, shell
300
+ # keywords and their options (and an option's value: ruby -I lib,
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.
305
+ def self.program_at(words)
306
+ at = 0
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
325
+ at < words.size ? at : nil
326
+ end
327
+
328
+ def self.option_width(wrapper, word)
329
+ return 1 if WRAPPER_FLAGS.fetch(wrapper, []).include?(word)
330
+
331
+ WRAPPER_VALUES.fetch(wrapper, []).include?(word) || word.match?(/\A-[IrCen]\z/) ? 2 : 1
332
+ end
333
+
334
+ # Where git's subcommand may start in its +args+ (indices): past its
335
+ # own options (GIT_FLAG_OPTIONS, GIT_VALUE_OPTIONS and their value,
336
+ # an option=value), every reading of an option it doesn't know (with
337
+ # its next word as a value and without). :config_option among them
338
+ # when one of those options is a -c or --config-env.
339
+ def self.git_command_starts(args)
340
+ starts = []
341
+ todo = [0]
342
+ seen = {}
343
+ until todo.empty?
344
+ i = todo.shift
345
+ next if seen[i]
346
+
347
+ seen[i] = true
348
+ word = args[i]
349
+ next starts << i if word.nil? || !word.start_with?("-")
350
+ next starts << :config_option if word.start_with?("-c", "--config-env")
351
+
352
+ if GIT_FLAG_OPTIONS.include?(word) || word.include?("=") then todo << (i + 1)
353
+ elsif GIT_VALUE_OPTIONS.include?(word) then todo << (i + 2)
354
+ else todo.push(i + 1, i + 2)
355
+ end
356
+ end
357
+ starts
358
+ end
359
+
360
+ # A git builtin by its dashed name (git-config, …/git-core/git-submodule,
361
+ # a +program+ word's basename) as git runs it: ["git", [sub, *args]];
362
+ # any other program as it is.
363
+ def self.git_form(program, args)
364
+ sub = program.to_s[/\Agit-([a-z][\w-]*)\z/, 1]
365
+ sub ? ["git", [sub, *args]] : [program, args]
366
+ end
367
+
368
+ # A shell, another interpreter or eval: what runs a script it is given.
369
+ def self.runner?(word)
370
+ return false if word.include?("\0")
371
+
372
+ name = ShellLex.program_name(word)
373
+ SHELLS.include?(name) || interpreter?(name) || name == "eval"
374
+ end
375
+
376
+ # Whether +name+ (a word's basename) is an interpreter's (INTERPRETER).
377
+ def self.interpreter?(name) = name.to_s.match?(INTERPRETER)
378
+
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"
385
+ return %w[-c] if SHELLS.include?(name)
386
+
387
+ CODE_FLAGS.find { |start, _| name.start_with?(start) }&.last || %w[-c -e]
388
+ end
389
+
390
+ # Whether +word+ is +name+'s code option: one of .code_flags, a long
391
+ # one with its code after = (node --eval=…), or a short-option
392
+ # cluster holding its code letter (.cluster_code_at: sh -ec, bash -cx,
393
+ # ruby -we, perl -le, node -pe, python3 -Ic).
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?
398
+ end
399
+
400
+ # The code a long code option carries after = (--eval=…), nil for
401
+ # another word.
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}=") }
404
+ flag && word.delete_prefix("#{flag}=")
405
+ end
406
+
407
+ # Where +word+, a short-option cluster, holds +name+'s code letter
408
+ # (a shell's c, python's c, ruby's e, node's e and p…): its index, the
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)
413
+ return nil unless word.match?(/\A-[A-Za-z0-9]/)
414
+
415
+ letters = code_flags(name, source: source).filter_map { |flag| flag[1] if flag.match?(/\A-[A-Za-z]\z/) }.join
416
+ return nil if letters.empty?
417
+
418
+ rest, digits = if SHELLS.include?(name)
419
+ ["", ""]
420
+ else
421
+ cluster_values(name, source ? SOURCE_CLUSTER_VALUES : CLUSTER_VALUES)
422
+ end
423
+ i = 1
424
+ while i < word.size
425
+ char = word[i]
426
+ return i if letters.include?(char)
427
+ return nil if rest.include?(char)
428
+
429
+ i += 1
430
+ if digits.include?(char) then i += 1 while word[i]&.match?(/\d/)
431
+ elsif !char.match?(/[A-Za-z]/) then return nil
432
+ end
433
+ end
434
+ nil
435
+ end
436
+
437
+ def self.cluster_values(name, table)
438
+ values = table.find { |start, _| name.start_with?(start) }&.last || []
439
+ [values[0].to_s, values[1].to_s]
440
+ end
441
+ private_class_method :cluster_values
442
+
443
+ # The scripts +name+ (a shell or an interpreter, not eval) is handed
444
+ # in +rest+, the words after it: a shell's is the first word that
445
+ # isn't an option after its -c cluster (sh -c -- S, bash -c -x S, its
446
+ # -o's value skipped); an interpreter's is each code option's (one of
447
+ # .code_flags, a long one's after =, a cluster's rest and the next
448
+ # word: both are read when the rest isn't empty).
449
+ def self.code_scripts(name, rest)
450
+ return shell_scripts(name, rest) if SHELLS.include?(name)
451
+
452
+ found = []
453
+ rest.each_with_index do |word, i|
454
+ next unless code_flag?(name, word)
455
+
456
+ if (code = long_code(name, word)) then found << code
457
+ elsif (at = cluster_code_at(name, word)) && at + 1 < word.size then found << word[(at + 1)..]
458
+ end
459
+ found << rest[i + 1] if rest[i + 1] && long_code(name, word).nil?
460
+ end
461
+ found
462
+ end
463
+
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)
490
+ i = start + 1
491
+ while i < rest.size
492
+ word = rest[i]
493
+ return rest[i + 1] ? [rest[i + 1]] : [] if word == "--"
494
+ return [word] unless word.start_with?("-", "+")
495
+
496
+ i += SHELL_VALUE_OPTIONS.include?(word) ? 2 : 1
497
+ end
498
+ []
499
+ end
500
+ private_class_method :shell_script_after
501
+
502
+ # Where a simple command's shell or interpreter (.runner?) gets its
503
+ # script: nil without one; :inline for its code option's script
504
+ # (.code_flag?: a shell's -c, python's -c, node's -e/-p…), eval's
505
+ # words, a heredoc or here-string (#scripts reads them); :file for a
506
+ # script file or a module (-m) it names (not read); :stdin when it
507
+ # reads a pipe (+piped+, whatever its options: a -c script may read
508
+ # stdin too, exec(input())), a < file, -s, -, or no script at all.
509
+ # Other flags (a shell's -e, -x, -o x; python's -x) are flags.
510
+ def self.script_source(words, piped: false)
511
+ limit = program_at(words) || (words.size - 1)
512
+ at = (0..limit).find { |i| runner?(words[i]) } or return nil
513
+ name = ShellLex.program_name(words[at])
514
+ return :inline if name == "eval"
515
+ return :stdin if piped
516
+
517
+ rest = words[(at + 1)..]
518
+ return :inline if rest.any? { |word| word.start_with?("<<") }
519
+ return :stdin if rest.any? { |word| word.match?(STDIN_REDIRECT) }
520
+
521
+ skip = false
522
+ rest.each do |word|
523
+ next skip = false if skip
524
+ return :stdin if ["-", "-s"].include?(word) || (SHELLS.include?(name) && word.match?(/\A-[a-zA-Z]*s[a-zA-Z]*\z/))
525
+ return :inline if code_flag?(name, word, source: true)
526
+ return :file if word == "-m" || word.match?(/\A-m\w/)
527
+ # a redirection's operator alone: its target is the next word
528
+ next skip = word.sub(ShellPaths::REDIRECT_PREFIX, "").empty? if word.match?(ShellPaths::REDIRECT_PREFIX)
529
+ next skip = RUNNER_VALUE_OPTIONS.include?(word) if word.start_with?("-", "+")
530
+
531
+ return :file
532
+ end
533
+ :stdin
534
+ end
535
+
166
536
  # A command's text naming chi web's endpoint on this machine: a
167
537
  # loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
168
538
  # +ports+, anywhere in it (a URL's host:port, nc's host port, a
@@ -211,9 +581,20 @@ module Samagotchi
211
581
  @refusal = read
212
582
  rescue StandardError
213
583
  @mise_config = true
584
+ @net = true
214
585
  @refusal = PairBoundary::UNREADABLE
215
586
  end
216
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
+
217
598
  # Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
218
599
  # path through a link it makes now is judged before the link exists,
219
600
  # so the owner approves it (asked about; a link to a folder above a
@@ -255,7 +636,8 @@ module Samagotchi
255
636
  def outside_writes?
256
637
  return @outside_writes if defined?(@outside_writes)
257
638
 
258
- @outside_writes = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
639
+ @outside_writes = handed_command_outside? ||
640
+ !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
259
641
  rescue StandardError
260
642
  @outside_writes = true
261
643
  end
@@ -274,11 +656,14 @@ module Samagotchi
274
656
  return PairBoundary::CHI_WEB if @web && code.match?(@web)
275
657
  return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
276
658
 
277
- @read_only = ReadOnlyShell.read_only?(@text)
659
+ read_only = ReadOnlyShell.read_only_parts(tokens)
278
660
  walk = ShellPaths::Walk.new(@cwd, @home, ENV)
279
661
  ShellLex.simple_commands(tokens).each do |words|
280
662
  next walk.step(words) if words.is_a?(Symbol)
281
663
 
664
+ # A path the command names counts as written (mise's config, git's
665
+ # hooks) unless the part of it holding this command only reads.
666
+ @read_only = read_only.shift
282
667
  reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
283
668
  return reason if reason
284
669
  end
@@ -291,20 +676,60 @@ module Samagotchi
291
676
  return PairBoundary::CHI if chi_in_values?(words)
292
677
  return PairBoundary::VERB if at && unplain_program?(words[at])
293
678
 
294
- program = at && File.basename(words[at])
679
+ program = at && ShellLex.program_name(words[at])
295
680
  args = at ? words[(at + 1)..] : []
296
681
  return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
297
682
 
298
683
  upto = at ? words[0..at] : words
299
- return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
684
+ return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(ShellLex.program_name(w)) } || detached_session?(program, args)
300
685
  return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
301
- return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
686
+
687
+ program, args = PairCommand.git_form(program, args)
688
+ return PairBoundary::GIT_CONFIG if git_config_env?(words) || git_alias?(program, args)
689
+
690
+ handed_commands(words).each do |script|
691
+ reason = nested_refusal(script)
692
+ return reason if reason
693
+ end
694
+ reason = deferred_refusal(words)
695
+ return reason if reason
696
+
697
+ if program == "git"
698
+ # xargs adds words of its input: a git config it runs may write
699
+ xargs = words[0...at].any? { |word| ShellLex.program_name(word) == "xargs" }
700
+ reason = git_refusal(args, appended: xargs) || git_command_refusal(args)
701
+ return reason if reason
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
709
+ return PairBoundary::GIT_HOOKS if hook_installer?(words)
302
710
  return PairBoundary::OPENER if opener?(program, args)
303
711
  return PairBoundary::LISTENER if listener?(program, args)
304
712
 
713
+ exec_commands(args).each do |command|
714
+ reason = words_refusal(command)
715
+ return reason if reason
716
+ end
305
717
  nil
306
718
  end
307
719
 
720
+ # The commands find runs (-exec, -execdir, -ok, -okdir … ; or +):
721
+ # judged as commands of their own.
722
+ def exec_commands(args)
723
+ found = []
724
+ args.each_with_index do |arg, at|
725
+ next unless %w[-exec -execdir -ok -okdir].include?(arg)
726
+
727
+ command = args[(at + 1)..].take_while { |word| !%w[; \\; +].include?(word) }
728
+ found << command unless command.empty?
729
+ end
730
+ found
731
+ end
732
+
308
733
  # open, xdg-open, a browser, gio open; osascript's open location is
309
734
  # its script's (#script_text_refusal).
310
735
  def opener?(program, args)
@@ -350,12 +775,11 @@ module Samagotchi
350
775
  def scripts_refusal(words)
351
776
  limit = program_at(words) || (words.size - 1)
352
777
  at = (0..limit).find { |i| runner?(words[i]) } or return nil
353
- program = File.basename(words[at])
778
+ program = ShellLex.program_name(words[at])
354
779
  scripts(words, at).each do |script|
355
780
  reason = if SHELLS.include?(program) || program == "eval"
356
- inner = nested(script)
357
- inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
358
- elsif INTERPRETERS.include?(program)
781
+ nested_refusal(script)
782
+ elsif PairCommand.interpreter?(program)
359
783
  @mise_config = true if script.match?(MISE_CONFIG_TEXT)
360
784
  script_text_refusal(script)
361
785
  end
@@ -364,20 +788,18 @@ module Samagotchi
364
788
  nil
365
789
  end
366
790
 
367
- def runner?(word)
368
- name = File.basename(word)
369
- SHELLS.include?(name) || INTERPRETERS.include?(name) || name == "eval"
370
- end
791
+ def runner?(word) = PairCommand.runner?(word)
371
792
 
372
793
  def scripts(words, at)
794
+ name = ShellLex.program_name(words[at])
373
795
  found = []
374
- found << words[(at + 1)..].join(" ") if File.basename(words[at]) == "eval"
796
+ found << words[(at + 1)..].join(" ") if name == "eval"
375
797
  words.each_with_index do |word, i|
376
798
  next if i.zero? || word == ShellLex::HEREDOC
377
799
 
378
- previous = words[i - 1]
379
- found << word if previous.start_with?("<<") || (i > at && %w[-c -e].include?(previous))
800
+ found << word if words[i - 1].start_with?("<<")
380
801
  end
802
+ found.concat(PairCommand.code_scripts(name, words[(at + 1)..])) unless name == "eval"
381
803
  found
382
804
  end
383
805
 
@@ -388,27 +810,12 @@ module Samagotchi
388
810
  return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
389
811
  return PairBoundary::OPENER if script.match?(SCRIPT_OPENER_TEXT)
390
812
  return PairBoundary::LISTENER if script.match?(SCRIPT_LISTEN_TEXT)
813
+ return PairBoundary::GIT_CONFIG if script.match?(SCRIPT_GIT_CONFIG_TEXT)
391
814
 
392
815
  nil
393
816
  end
394
817
 
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
818
+ def program_at(words) = PairCommand.program_at(words)
412
819
 
413
820
  # screen -d…, tmux new -d: a session left running.
414
821
  def detached_session?(program, args)
@@ -442,43 +849,330 @@ module Samagotchi
442
849
  end
443
850
  end
444
851
 
445
- # 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).
446
856
  def bare_env?(words)
447
857
  assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
448
858
  at = words.index { |word| !assignment.call(word) }
449
- return false unless at && File.basename(words[at]) == "env"
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
875
+ end
876
+
877
+ # Why a git command is refused: a config write (anything but a read
878
+ # .git_config_read? holds for, git -c and --config-env whatever they
879
+ # set, a clone's, init's or submodule's -c/--config: GIT_CONFIG) or a
880
+ # template (--template: its hooks, GIT_HOOKS); nil for the rest.
881
+ def git_refusal(args, appended: false)
882
+ starts = PairCommand.git_command_starts(args)
883
+ return PairBoundary::GIT_CONFIG if starts.include?(:config_option)
884
+
885
+ starts.each do |i|
886
+ reason = git_command_refusal_at(args[i], args[(i + 1)..] || [], appended: appended)
887
+ return reason if reason
888
+ end
889
+ nil
890
+ end
891
+
892
+ def git_command_refusal_at(command, rest, appended:)
893
+ return (!appended && git_config_read?(rest) ? nil : PairBoundary::GIT_CONFIG) if command == "config"
894
+ return nil unless GIT_SEEDING.include?(command)
895
+ return PairBoundary::GIT_CONFIG if rest.any? { |arg| arg.match?(GIT_SEED_CONFIG) }
896
+
897
+ rest.any? { |arg| arg.match?(GIT_SEED_TEMPLATE) } ? PairBoundary::GIT_HOOKS : nil
898
+ end
899
+
900
+ # Whether git config's arguments (+rest+) only read, every word
901
+ # accounted for: a read action or subcommand (GIT_CONFIG_READ_ACTIONS,
902
+ # GIT_CONFIG_READS) or a lone key, the flags and value options a read
903
+ # may carry (exact spellings), and as many words as the action takes.
904
+ # Anything else (an unknown or abbreviated option, a value option at
905
+ # the end, a word too many) is a write.
906
+ def git_config_read?(rest)
907
+ actions = []
908
+ positional = []
909
+ skip = false
910
+ rest.each do |arg|
911
+ next skip = false if skip
912
+
913
+ if arg.start_with?("-") && arg != "-"
914
+ if GIT_CONFIG_READ_ACTIONS.include?(arg) then actions << arg
915
+ elsif GIT_CONFIG_READ_VALUES.include?(arg) then skip = true
916
+ elsif !GIT_CONFIG_READ_FLAGS.include?(arg) && !GIT_CONFIG_READ_VALUES.include?(arg[/\A[^=]+(?==)/])
917
+ return false
918
+ end
919
+ else
920
+ positional << arg
921
+ end
922
+ end
923
+ return false if skip
924
+
925
+ if GIT_CONFIG_READS.include?(positional.first)
926
+ return false unless actions.empty?
927
+
928
+ actions << positional.shift
929
+ end
930
+ return positional.size == 1 && !GIT_CONFIG_WRITES.include?(positional.first) if actions.empty?
931
+ return false unless actions.size == 1
932
+
933
+ GIT_CONFIG_READ_ARITY.fetch(actions.first, 1..2).cover?(positional.size)
934
+ end
935
+
936
+ # The commands a git command runs now, handed to it as text (a
937
+ # submodule foreach's, a rebase --exec's, a filter's, an --upload-pack,
938
+ # an ext:: URL…; GIT_COMMAND_OPTIONS): read as commands of their own.
939
+ def git_commands(args)
940
+ PairCommand.git_command_starts(args).grep(Integer).flat_map do |i|
941
+ git_commands_at(args[i], args[(i + 1)..] || [])
942
+ end
943
+ end
944
+
945
+ def git_commands_at(command, rest)
946
+ found = rest.filter_map { |arg| arg.delete_prefix("ext::") if arg.start_with?("ext::") }
947
+ found << rest.drop_while { |arg| arg != "foreach" }.drop(1).reject { |arg| arg.start_with?("-") }.join(" ") if
948
+ command == "submodule" && rest.include?("foreach")
949
+ found << rest.drop_while { |arg| arg != "run" }.drop(1).join(" ") if command == "bisect" && rest.include?("run")
950
+ rest.each_with_index do |arg, at|
951
+ name, value = arg.split("=", 2)
952
+ next unless GIT_COMMAND_OPTIONS.include?(name) || (%w[-x -u].include?(name) && GIT_COMMAND_SHORT.include?(command))
953
+
954
+ found << (value || rest[at + 1].to_s)
955
+ end
956
+ found
957
+ end
958
+
959
+ # A command a git command runs now (.git_commands), refused as its
960
+ # own command would be.
961
+ def git_command_refusal(args)
962
+ git_commands(args).each do |script|
963
+ reason = nested_refusal(script)
964
+ return reason if reason
965
+ end
966
+ nil
967
+ end
968
+
969
+ # alias g=git (or a function's body naming git), then `g config …`:
970
+ # git under another name; an alias naming git is refused.
971
+ def git_alias?(program, args)
972
+ program == "alias" && args.any? { |arg| arg.match?(%r{=['"]?(?:\S*/)?git(?:\s|['"]?\z)}i) }
973
+ end
974
+
975
+ # An assignment (a prefix, export's, env's) of a variable that hands
976
+ # git config or a template of its own (ShellGitDirs::CONFIG_ENV): what
977
+ # it sets may run a command, as git -c's may.
978
+ def git_config_env?(words)
979
+ words.any? { |word| word[/\A([A-Za-z_]\w*)=/, 1].to_s.match?(ShellGitDirs::CONFIG_ENV) }
980
+ end
981
+
982
+ # The commands a simple command's assignments hand a program
983
+ # (COMMAND_ENV: GIT_EDITOR='…' git commit, export GIT_PAGER=…).
984
+ def env_commands(words)
985
+ words.filter_map do |word|
986
+ name, value = word.match(/\A([A-Za-z_]\w*)=(.+)\z/m)&.captures
987
+ value if COMMAND_ENV.include?(name)
988
+ end
989
+ end
990
+
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
450
1028
 
451
- words[(at + 1)..].all?(&assignment)
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
452
1058
  end
453
1059
 
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))
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
462
1109
 
463
- i += 2
464
- elsif GIT_VALUE_OPTIONS.include?(args[i])
465
- i += 2
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?
466
1142
  else
467
- i += 1
1143
+ program == "chi" && chi_call?(words[i + 1])
468
1144
  end
469
1145
  end
470
- return false unless args[i] == "config"
1146
+ end
471
1147
 
472
- rest = args[(i + 1)..]
473
- return true if rest.any? { |arg| GIT_WIDE_SCOPES.include?(arg) }
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?("-")
474
1153
 
475
- key, value = rest.reject { |arg| arg.start_with?("-") }
476
- running_setting?(key, value)
1154
+ CHI_COMMANDS.include?(first)
477
1155
  end
478
1156
 
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?("!"))
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?
1160
+ ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
1161
+ words.is_a?(Array) && handed_commands(words).any? { |script| nested(script).outside_writes? }
1162
+ end
1163
+ end
1164
+
1165
+ # A hook installer (HOOK_INSTALLERS: pre-commit install, husky,
1166
+ # lefthook install…), wherever it stands among the wrappers (npx husky).
1167
+ def hook_installer?(words)
1168
+ at = program_at(words) or return false
1169
+ (0..at).any? do |i|
1170
+ name = ShellLex.program_name(words[i])
1171
+ next false unless HOOK_INSTALLERS.key?(name)
1172
+
1173
+ verbs = HOOK_INSTALLERS[name]
1174
+ verbs.nil? || words[(i + 1)..].intersect?(verbs)
1175
+ end
482
1176
  end
483
1177
 
484
1178
  # The secrets a simple command's words reach: a resolved path on the
@@ -486,7 +1180,7 @@ module Samagotchi
486
1180
  # denied dir; a $var word whose fixed part does; a sweeper given a
487
1181
  # folder above a denied one; SECRET_TEXT for a word it can't read.
488
1182
  def paths_refusal(words, resolved)
489
- program_word = (at = program_at(words)) && File.basename(words[at])
1183
+ program_word = (at = program_at(words)) && ShellLex.program_name(words[at])
490
1184
  resolved.each do |word, path|
491
1185
  return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
492
1186
 
@@ -544,13 +1238,13 @@ module Samagotchi
544
1238
 
545
1239
  def link_command?(words)
546
1240
  at = program_at(words)
547
- program = at && File.basename(words[at])
1241
+ program = at && ShellLex.program_name(words[at])
548
1242
  return true if LINKERS.include?(program)
549
1243
  return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
550
- return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
1244
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(ShellLex.program_name(word)) }
551
1245
 
552
1246
  runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
553
- name = File.basename(words[runner])
1247
+ name = ShellLex.program_name(words[runner])
554
1248
  scripts(words, runner).any? do |script|
555
1249
  SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
556
1250
  end
@@ -558,11 +1252,12 @@ module Samagotchi
558
1252
 
559
1253
  def env_loader_command?(words)
560
1254
  at = program_at(words)
561
- return true if at && ENV_LOADERS.include?(File.basename(words[at]))
562
- return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(File.basename(word)) }
1255
+ # wherever it stands among the wrappers too (mise exec -- env)
1256
+ return true if words[0..at].any? { |word| ENV_LOADERS.any? { |name| ShellLex.program?(word, name) } }
1257
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(ShellLex.program_name(word)) }
563
1258
 
564
1259
  runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
565
- name = File.basename(words[runner])
1260
+ name = ShellLex.program_name(words[runner])
566
1261
  scripts(words, runner).any? do |script|
567
1262
  SHELLS.include?(name) || name == "eval" ? nested(script).loads_env? : script.match?(ENV_LOADER_TEXT)
568
1263
  end
@@ -591,12 +1286,59 @@ module Samagotchi
591
1286
  return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
592
1287
 
593
1288
  walk = ShellPaths::Walk.new(@cwd, @home, ENV)
1289
+ piped = ShellLex.piped_flags(tokens)
594
1290
  ShellLex.simple_commands(tokens).all? do |words|
595
1291
  next walk.step(words) if words.is_a?(Symbol)
596
1292
 
1293
+ stdin = PairCommand.script_source(words, piped: piped.shift) == :stdin || git_hands_command?(words)
597
1294
  walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
598
- scripts_inside?(words).tap { walk.step(words) }
1295
+ !implicit_outside?(words, walk) && !stdin && scripts_inside?(words).tap { walk.step(words) }
1296
+ end
1297
+ end
1298
+
1299
+ # A program that writes to its own place outside with no word naming
1300
+ # it (ImplicitWriters: npm -g, gem install, brew, defaults write, …),
1301
+ # wherever it stands among the wrappers (sudo npm, bundle config),
1302
+ # python -m's module as the program, under the assignments before it
1303
+ # (npm_config_global=true).
1304
+ def implicit_outside?(words, walk)
1305
+ at = program_at(words) or return false
1306
+ assigned = words[0...at].filter_map { |word| word.split("=", 2) if word.match?(/\A[A-Za-z_]\w*=/) }.to_h
1307
+ (0..at).any? do |i|
1308
+ program = words[i]
1309
+ args = words[(i + 1)..]
1310
+ local = project_program?(walk, program)
1311
+ if PairCommand.interpreter?(ShellLex.program_name(program)) && (module_args = module_run(args))
1312
+ program, args = module_args
1313
+ end
1314
+ ImplicitWriters.outside?(program, args, local: local, assigned: assigned)
1315
+ end
1316
+ end
1317
+
1318
+ # An interpreter's -m module and the words after it ([name, args]):
1319
+ # -m pip, -mpip; nil without one.
1320
+ def module_run(args)
1321
+ args.each_with_index do |arg, i|
1322
+ return [args[i + 1], args[(i + 2)..]] if arg == "-m" && args[i + 1]
1323
+ return [arg.delete_prefix("-m"), args[(i + 1)..]] if arg.match?(/\A-m\w/)
599
1324
  end
1325
+ nil
1326
+ end
1327
+
1328
+ # Whether a git command runs a command now that it is handed as text
1329
+ # (.git_commands): where it writes isn't placed, so it asks.
1330
+ def git_hands_command?(words)
1331
+ at = program_at(words) or return false
1332
+ program, args = PairCommand.git_form(ShellLex.program_name(words[at]), words[(at + 1)..])
1333
+ program == "git" && !git_commands(args).empty?
1334
+ end
1335
+
1336
+ # Whether a program's word is a path into the project (.venv/bin/pip).
1337
+ def project_program?(walk, word)
1338
+ return false unless word.include?("/")
1339
+
1340
+ paths = walk.paths(word)
1341
+ !paths.empty? && paths.all? { |path| Outside.inside?(path, root: @root) }
600
1342
  end
601
1343
 
602
1344
  # Whether every $(…) is a message's heredoc (its body cut as data).
@@ -610,10 +1352,10 @@ module Samagotchi
610
1352
  def words_inside?(words, walk)
611
1353
  at = program_at(words)
612
1354
  # xargs runs its program on paths it reads from its input
613
- return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
1355
+ return false if at && words[0...at].any? { |word| ShellLex.program_name(word) == "xargs" }
614
1356
 
615
1357
  words.each_with_index.all? do |word, i|
616
- next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
1358
+ next true if at && (i == at || (i < at && WRAPPERS.include?(ShellLex.program_name(word))))
617
1359
  next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
618
1360
 
619
1361
  word_inside?(walk, word)
@@ -657,7 +1399,7 @@ module Samagotchi
657
1399
  def scripts_inside?(words)
658
1400
  limit = program_at(words) || (words.size - 1)
659
1401
  at = (0..limit).find { |i| runner?(words[i]) } or return true
660
- program = File.basename(words[at])
1402
+ program = ShellLex.program_name(words[at])
661
1403
  scripts(words, at).all? do |script|
662
1404
  if SHELLS.include?(program) || program == "eval"
663
1405
  !nested(script).outside_writes?