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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
147
|
-
# their [env] at the next worker
|
|
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 =
|
|
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
|
-
|
|
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 &&
|
|
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?(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
455
|
-
#
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
1143
|
+
program == "chi" && chi_call?(words[i + 1])
|
|
468
1144
|
end
|
|
469
1145
|
end
|
|
470
|
-
|
|
1146
|
+
end
|
|
471
1147
|
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
476
|
-
running_setting?(key, value)
|
|
1154
|
+
CHI_COMMANDS.include?(first)
|
|
477
1155
|
end
|
|
478
1156
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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)) &&
|
|
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 &&
|
|
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?(
|
|
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 =
|
|
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
|
-
|
|
562
|
-
return true if words.
|
|
1255
|
+
# wherever it stands among the wrappers too (mise exec -- env)
|
|
1256
|
+
return true if words[0..at].any? { |word| ENV_LOADERS.any? { |name| ShellLex.program?(word, name) } }
|
|
1257
|
+
return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(ShellLex.program_name(word)) }
|
|
563
1258
|
|
|
564
1259
|
runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
|
|
565
|
-
name =
|
|
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|
|
|
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?(
|
|
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 =
|
|
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?
|