samagotchi 0.128.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tmpdir"
3
+ require_relative "outside"
4
+ require_relative "path_resolve"
5
+ require_relative "read_only_shell"
4
6
  require_relative "review_paths"
5
7
  require_relative "shell_paths"
6
8
  require_relative "shell_lex"
@@ -10,17 +12,23 @@ module Samagotchi
10
12
  # One shell command of a participant's pair turn, read the way
11
13
  # PairBoundary needs it (the refusal words are PairBoundary's):
12
14
  # - #refusal: why it may not run, nil when it may: chi, something left
13
- # running, the environment or the keychain, git settings that run
15
+ # running, a URL opener or a listener (OPENERS, LISTENERS: the owner's
16
+ # browser cookies), the environment or the keychain, git settings that run
14
17
  # commands, a folder sweep, a secret (a path a word resolves to, a glob
15
18
  # or a $var whose fixed part leads into one, SECRET_TEXT for the
16
19
  # rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
17
20
  # a command of its own, one it hands another interpreter (ruby -e,
18
21
  # python <<EOF) by its text. A command it can't read is refused
19
22
  # (UNREADABLE), never let through on a text match;
20
- # - #outside_writes?: whether it writes outside the project (a
21
- # redirection's target, tee's files, cp/mv/ln/install/rsync/ditto's
22
- # destination), the tmp dirs and /dev/null aside; a target it can't
23
- # resolve counts as outside.
23
+ # - #outside_writes?: whether it may change something outside the
24
+ # project, failing closed: no unless it only reads
25
+ # (ReadOnlyShell.read_only?), or only reads but for writes into the
26
+ # project or a tmp dir (ReadOnlyShell.read_only_but_writes?), or
27
+ # stays inside the project (#stays_inside?: it runs in the project and
28
+ # every path it names, an option's value, a glob's fixed part and the
29
+ # paths in a script it hands on among them, lands in the project or a
30
+ # tmp dir, Outside.inside? / Outside.tmp_write?). A word it can't
31
+ # resolve ($var, $(…), a dir after cd $X) counts as outside.
24
32
  # A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
25
33
  # is not looked into: it's text being written, not run.
26
34
  class PairCommand
@@ -37,45 +45,140 @@ module Samagotchi
37
45
  INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
38
46
  # Options whose next word is code or a command, for any program.
39
47
  CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
40
- # Programs that detach what they start.
41
- DETACHERS = %w[nohup setsid disown daemonize].freeze
48
+ # Programs that detach what they start, or run it later (cron, at).
49
+ DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
42
50
  # Programs that print the environment or a keychain's secrets, whatever
43
51
  # their arguments.
44
- ENV_READERS = %w[printenv security].freeze
52
+ ENV_READERS = %w[printenv security op].freeze
53
+ # Commands that print a stored credential (a token, a password, a
54
+ # key), by the program and the words that say so in its arguments.
55
+ CREDENTIAL_PRINTERS = {
56
+ "gh" => [%w[auth token]],
57
+ "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
58
+ %w[credential-manager]],
59
+ "gcloud" => [%w[print-access-token], %w[print-identity-token]],
60
+ "aws" => [%w[configure get], %w[configure export-credentials], %w[ecr get-login-password],
61
+ %w[sts get-session-token]],
62
+ "az" => [%w[account get-access-token]],
63
+ "heroku" => [%w[auth:token]],
64
+ "npm" => [%w[token]],
65
+ "kubectl" => [%w[config view --raw]],
66
+ "keyring" => [%w[get]]
67
+ }.freeze
68
+ # A credential helper run by itself (docker-credential-osxkeychain get).
69
+ CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
45
70
  ENV_TEXT = %r{/proc/[^\s]*/environ}
71
+ # Programs that open a URL or a file in the owner's apps (a browser
72
+ # sends the owner's cookies with it), whatever their arguments; gio
73
+ # with its open verb.
74
+ OPENERS = %w[open xdg-open gnome-open kde-open kde-open5 kfmclient exo-open wslview sensible-browser
75
+ x-www-browser www-browser google-chrome google-chrome-stable chromium chromium-browser
76
+ firefox safari msedge microsoft-edge brave-browser].freeze
77
+ # Programs that listen on a port whatever their arguments (static
78
+ # HTTP servers), and the netcats, which listen with -l.
79
+ LISTENERS = %w[http-server live-server httpd lighttpd nginx caddy miniserve darkhttpd webfsd].freeze
80
+ NETCATS = %w[nc ncat netcat].freeze
81
+ NC_LISTEN = /\A-[a-zA-Z0-9]*l|\A--listen\z/
82
+ # An interpreter's flags or arguments that serve a port (python -m
83
+ # http.server, ruby -run -e httpd, php -S, busybox httpd).
84
+ SERVE_MODULES = %w[http.server SimpleHTTPServer CGIHTTPServer socketserver].freeze
85
+ # An interpreter's script that opens a URL in the owner's apps, or
86
+ # listens on a port.
87
+ SCRIPT_OPENER_TEXT = /\bwebbrowser\b|\bopen location\b|openExternal|xdg-open|\bopen\s+["']?https?:/
88
+ SCRIPT_LISTEN_TEXT = /\blisten\s*\(|\bhttp\.server\b|\bserve_forever\b|HTTPServer|TCPServer|socketserver|createServer|WEBrick|\bhttpd\b/
89
+ # This machine's loopback names (.web_text): localhost and its
90
+ # subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
91
+ LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
92
+ /\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
46
93
  # An interpreter's script that reads the environment or detaches.
47
94
  SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
48
95
  SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
49
96
  # Programs that read a folder's whole tree: given home (or any folder
50
97
  # above a denied one) they reach the secrets in it.
51
- SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto].freeze
98
+ # A link to such a folder reaches them too (ln, link).
99
+ SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto ln link].freeze
100
+ # Programs that make a link (and cp's options that do).
101
+ LINKERS = %w[ln link mklink].freeze
102
+ CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
103
+ # An interpreter's script that makes a link.
104
+ SCRIPT_LINK_TEXT = /symlink|\blink\(|\bln\s|\bmklink\b|FileUtils\.ln|hardlink/i
52
105
  # git settings that make git run a command (or show one), and git
53
106
  # config's scopes beyond the repository.
54
107
  GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
55
108
  GIT_WIDE_SCOPES = %w[--global --system].freeze
56
109
  GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
57
- # Writers whose last argument (or -t DIR) is the destination.
58
- COPIERS = %w[cp mv ln install rsync ditto].freeze
59
- REDIRECTS = %w[> >> &> &>> 1> 1>> 2> 2>>].freeze
60
- REDIRECT_WORD = /\A\d*(?:&>>?|>>?)(?!&)(.+)\z/
61
- DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty].freeze
110
+ # Devices a command may name in the project's stead.
111
+ DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
112
+ # A word that starts with $TMPDIR or ${TMPDIR}.
113
+ TMPDIR_WORD = /\A\$(?:\{TMPDIR\}|TMPDIR\b)/
114
+
115
+ # A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
116
+ # or an absolute path, at the text's start or after a space, quote,
117
+ # bracket, = or comma.
118
+ SCRIPT_PATH = %r{(?:\A|(?<=[\s'"(\[=,]))(?:~|\.\.|/[\w.~-])[^\s'"`;,()\]]*}
119
+ # A $(…) that is only a message's heredoc (git commit -m "$(cat <<EOF …)"),
120
+ # its body cut by ShellLex.code_text.
121
+ MESSAGE_SUBST = /\$\(cat <<-?(['"]?)(\w+)\1\n\2\n\)/
122
+ # Assignments whose value is a search list, not a path.
123
+ LIST_VARIABLES = %w[PATH].freeze
62
124
  GLOB = /[*?\[\]{}]/
125
+ SHELL_BRACKETS = %w[[ [[ ]] { } ]].freeze
63
126
  # How many matches of a glob are checked against the denylist.
64
127
  GLOB_LIMIT = 500
65
128
  # How deep scripts handed to a shell are read; deeper is UNREADABLE.
66
129
  MAX_DEPTH = 3
67
130
 
68
- # The tmp dirs a pair turn writes into freely (neither asked nor
69
- # denied): Dir.tmpdir, $TMPDIR, /tmp, /private/tmp.
70
- def self.tmp_roots
71
- [Dir.tmpdir, ENV.fetch("TMPDIR", nil), "/tmp", "/private/tmp"].compact.reject(&:empty?)
72
- .map { |dir| ProtectedPaths.real(dir) }.uniq
131
+ # Whether a pair turn may write +path+ (absolute) without asking: it
132
+ # lands in the project (Outside.inside?) or in a tmp dir the project
133
+ # isn't in (Outside.tmp_write?), every way it resolves. No project
134
+ # (+root+ blank): the tmp dirs only.
135
+ def self.writable?(path, root:, cwd: nil)
136
+ root = root.to_s
137
+ return Outside.tmp_write?(path, root: cwd || Dir.pwd) if root.empty?
138
+
139
+ Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
140
+ end
141
+
142
+ # Whether +path+ is in a .git/hooks folder, as given or as it resolves
143
+ # (ShellPaths::HOOKS_PART): git runs what is there.
144
+ def self.git_hook?(path)
145
+ path.to_s.match?(ShellPaths::HOOKS_PART) ||
146
+ (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
147
+ end
148
+
149
+ # A command's text naming chi web's endpoint on this machine: a
150
+ # loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
151
+ # +ports+, anywhere in it (a URL's host:port, nc's host port, a
152
+ # script's ("host", port), /dev/tcp/host/port, curl's --resolve); or
153
+ # 0:port. nil without a port.
154
+ # @return [Regexp, nil]
155
+ def self.web_text(ports, hostnames: [])
156
+ return nil if ports.empty?
157
+
158
+ hosts = (LOOPBACK_HOSTS.map(&:source) + hostnames.map { |name| Regexp.escape(name) }).join("|")
159
+ port = ports.map { |number| Regexp.escape(number.to_s) }.join("|")
160
+ Regexp.new("\\A(?=[\\s\\S]*?(?<![\\w.-])(?:#{hosts})(?![\\w-]))(?=[\\s\\S]*?(?<![\\w.])(?:#{port})(?!\\d))|" \
161
+ "(?<![\\w.:-])0:(?:#{port})(?!\\d)", Regexp::IGNORECASE)
162
+ end
163
+
164
+ # This machine's own names (Socket.gethostname, with and without
165
+ # its domain and with .local), lowercased.
166
+ def self.hostnames
167
+ require "socket"
168
+ name = Socket.gethostname.to_s.downcase
169
+ short = name.split(".").first.to_s
170
+ [name, short, "#{short}.local"].reject { |host| host.empty? || host == ".local" }.uniq
171
+ rescue StandardError
172
+ []
73
173
  end
74
174
 
75
175
  # @param paths [ReviewPaths] the denylist
76
- # @param root [String, nil] the project root (outside writes)
77
- def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, tmp_roots: nil)
78
- @tmp_roots = tmp_roots
176
+ # @param root [String, nil] the project root (outside writes; none:
177
+ # every command but a read-only one writes outside)
178
+ # @param web [Regexp, nil] chi web's endpoint in a command's text
179
+ # (.web_text)
180
+ def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, web: nil)
181
+ @web = web
79
182
  @text = text.to_s
80
183
  @cwd = cwd
81
184
  @paths = paths
@@ -93,10 +196,28 @@ module Samagotchi
93
196
  @refusal = PairBoundary::UNREADABLE
94
197
  end
95
198
 
96
- # Whether it writes outside the project (asked about, never denied).
199
+ # Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
200
+ # path through a link it makes now is judged before the link exists,
201
+ # so the owner approves it (asked about; a link to a folder above a
202
+ # secret is refused: SWEEPERS). True for one it can't read.
203
+ def links?
204
+ return @links if defined?(@links)
205
+
206
+ @links = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
207
+ words.is_a?(Array) && link_command?(words)
208
+ end
209
+ rescue StandardError
210
+ @links = true
211
+ end
212
+
213
+ # Whether it may write outside the project (asked about, never
214
+ # denied); true for one it can't read.
97
215
  def outside_writes?
98
- refusal
99
- @targets.to_a.any? { |path| outside?(path) }
216
+ return @outside_writes if defined?(@outside_writes)
217
+
218
+ @outside_writes = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
219
+ rescue StandardError
220
+ @outside_writes = true
100
221
  end
101
222
 
102
223
  private
@@ -110,14 +231,14 @@ module Samagotchi
110
231
  return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
111
232
  return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
112
233
  return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
234
+ return PairBoundary::CHI_WEB if @web && code.match?(@web)
113
235
  return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
114
236
 
115
- @targets = []
237
+ @read_only = ReadOnlyShell.read_only?(@text)
116
238
  walk = ShellPaths::Walk.new(@cwd, @home, ENV)
117
239
  ShellLex.simple_commands(tokens).each do |words|
118
240
  next walk.step(words) if words.is_a?(Symbol)
119
241
 
120
- @targets.concat(write_targets(words, walk))
121
242
  reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
122
243
  return reason if reason
123
244
  end
@@ -127,19 +248,53 @@ module Samagotchi
127
248
  # A simple command's own refusal, by its program and arguments.
128
249
  def words_refusal(words)
129
250
  at = program_at(words)
251
+ return PairBoundary::CHI if chi_in_values?(words)
252
+ return PairBoundary::VERB if at && unplain_program?(words[at])
253
+
130
254
  program = at && File.basename(words[at])
131
255
  args = at ? words[(at + 1)..] : []
132
256
  return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
133
- return PairBoundary::CHI if chi_in_values?(words)
134
257
 
135
258
  upto = at ? words[0..at] : words
136
259
  return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
137
260
  return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
138
261
  return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
262
+ return PairBoundary::OPENER if opener?(program, args)
263
+ return PairBoundary::LISTENER if listener?(program, args)
139
264
 
140
265
  nil
141
266
  end
142
267
 
268
+ # open, xdg-open, a browser, gio open; osascript's open location is
269
+ # its script's (#script_text_refusal).
270
+ def opener?(program, args)
271
+ OPENERS.include?(program.to_s.downcase) || (program == "gio" && args.first == "open")
272
+ end
273
+
274
+ # nc -l, socat with a …LISTEN address, a static HTTP server, an
275
+ # interpreter serving a module (python -m http.server), ruby's un
276
+ # httpd (ruby -run -e httpd, ruby being a wrapper word: the args are
277
+ # the whole command's), php -S, busybox httpd.
278
+ def listener?(program, args)
279
+ name = program.to_s
280
+ return true if LISTENERS.include?(name)
281
+ return true if NETCATS.include?(name) && args.any? { |arg| arg.match?(NC_LISTEN) }
282
+ return true if name == "socat" && args.any? { |arg| arg.match?(/LISTEN/i) }
283
+ return true if name.start_with?("php") && args.include?("-S")
284
+ return true if name == "busybox" && args.first == "httpd"
285
+ return true if args.each_cons(2).any? { |flag, value| flag == "-m" && SERVE_MODULES.include?(value) }
286
+
287
+ false
288
+ end
289
+
290
+ # A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
291
+ # ${e}i); the test brackets and braces are words of their own.
292
+ def unplain_program?(word)
293
+ return false if SHELL_BRACKETS.include?(word)
294
+
295
+ word.include?("$") || word.include?(ShellLex::SUBST) || word.match?(GLOB)
296
+ end
297
+
143
298
  # chi in an assignment's value (FOO=chi, alias x='chi …') or in the
144
299
  # word after an option that takes code or a command (-exec, -c, -e).
145
300
  def chi_in_values?(words)
@@ -158,8 +313,7 @@ module Samagotchi
158
313
  program = File.basename(words[at])
159
314
  scripts(words, at).each do |script|
160
315
  reason = if SHELLS.include?(program) || program == "eval"
161
- PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root, depth: @depth + 1,
162
- tmp_roots: @tmp_roots).refusal
316
+ nested(script).refusal
163
317
  elsif INTERPRETERS.include?(program)
164
318
  script_text_refusal(script)
165
319
  end
@@ -190,6 +344,8 @@ module Samagotchi
190
344
  return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
191
345
  return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
192
346
  return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
347
+ return PairBoundary::OPENER if script.match?(SCRIPT_OPENER_TEXT)
348
+ return PairBoundary::LISTENER if script.match?(SCRIPT_LISTEN_TEXT)
193
349
 
194
350
  nil
195
351
  end
@@ -218,21 +374,32 @@ module Samagotchi
218
374
  (program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
219
375
  end
220
376
 
221
- # printenv, security; set, export, declare and typeset that list
222
- # (no assignment); launchctl getenv/export; ps with a BSD e (ps e, eww).
377
+ # printenv, security, op, a credential printer (CREDENTIAL_PRINTERS)
378
+ # or helper; set, export, declare and typeset that list (no
379
+ # assignment); launchctl getenv/export; ps with a BSD e (ps e, eww) or
380
+ # -E (ps -E, -axE).
223
381
  def env_reader?(program, args)
224
- return true if ENV_READERS.include?(program)
382
+ return true if ENV_READERS.include?(program) || program.to_s.match?(CREDENTIAL_HELPER)
383
+ return true if credential_printer?(program, args)
225
384
 
226
385
  listing = args.none? { |arg| !arg.start_with?("-", "+") }
227
386
  case program
228
387
  when "set" then args.empty?
229
388
  when "export", "declare", "typeset" then listing
230
389
  when "launchctl" then %w[getenv export].include?(args.first)
231
- when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z/) }
390
+ when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z|\A-[a-zA-Z]*E[a-zA-Z]*\z/) }
232
391
  else false
233
392
  end
234
393
  end
235
394
 
395
+ # Whether +args+ hold one of +program+'s CREDENTIAL_PRINTERS words, in
396
+ # order and next to each other (git -C x credential fill).
397
+ def credential_printer?(program, args)
398
+ CREDENTIAL_PRINTERS.fetch(program, []).any? do |words|
399
+ args.each_cons(words.size).any?(words)
400
+ end
401
+ end
402
+
236
403
  # env with no command after it prints the environment.
237
404
  def bare_env?(words)
238
405
  assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
@@ -279,6 +446,8 @@ module Samagotchi
279
446
  def paths_refusal(words, resolved)
280
447
  program_word = (at = program_at(words)) && File.basename(words[at])
281
448
  resolved.each do |word, path|
449
+ return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
450
+
282
451
  if path
283
452
  return PairBoundary::SECRET if @paths.secret_refusal([path])
284
453
  return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
@@ -293,12 +462,16 @@ module Samagotchi
293
462
 
294
463
  # A word Walk can't resolve: a glob checked by what it matches and by
295
464
  # its fixed part, a $var by its fixed part (the text before the first
296
- # glob character or $, with ~ and $HOME expanded). A script body
297
- # (spaces, quotes), a data heredoc or a $(…) is left to the other checks.
465
+ # glob character or $, with a leading ~, $HOME, XDG dir or $TMPDIR
466
+ # expanded: PathResolve.expand_head). After $TMPDIR the fixed part
467
+ # counts only inside a denied root, not as the start of one's name: an
468
+ # isolated run keeps its state dir in tmp, and `ls $TMPDIR/*` is no
469
+ # reach for it. A script body (spaces, quotes), a data heredoc or a
470
+ # $(…) is left to the other checks.
298
471
  def unresolved_secret?(word)
299
472
  return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
300
473
 
301
- expanded = word.sub(%r{\A~(?=/|\z)}, @home).sub(/\A\$(?:\{HOME\}|HOME\b)/, @home)
474
+ expanded = PathResolve.expand_head(word, home: @home, env: ENV)
302
475
  fixed = expanded[/\A[^*?\[\]{}$]*/]
303
476
  dir = @cwd || Dir.pwd
304
477
  if !expanded.include?("$") && expanded.match?(GLOB)
@@ -307,59 +480,141 @@ module Samagotchi
307
480
  end
308
481
  return false if fixed.empty?
309
482
 
310
- leads_into_denied?(File.expand_path(fixed, dir))
483
+ prefix = File.expand_path(fixed, dir)
484
+ word.match?(TMPDIR_WORD) ? inside_denied?(prefix) : leads_into_denied?(prefix)
311
485
  end
312
486
 
487
+ # Whether a path's text is a denied root or inside one.
488
+ def inside_denied?(path) = @paths.denied_roots.any? { |root| PathResolve.under?(path, root) }
489
+
313
490
  # Whether a path's text is the start of a denied root's (its parent,
314
491
  # or part of its name: ~/.ss) or inside one.
315
492
  def leads_into_denied?(prefix)
316
- @paths.denied_roots.any? { |root| root.start_with?(prefix) || ShellPaths.within?(prefix, root) }
493
+ @paths.denied_roots.any? { |root| root.start_with?(prefix) || PathResolve.under?(prefix, root) }
317
494
  end
318
495
 
319
496
  # Whether +path+ is a denied root's folder or one above it.
320
497
  def above_denied?(path)
321
- real = ProtectedPaths.real(path)
322
- @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
498
+ PathResolve.reals(path).any? { |real| @paths.denied_roots.any? { |root| PathResolve.under?(root, real) } }
323
499
  end
324
500
 
325
- # The paths a simple command writes (nil for one it can't resolve), from
326
- # the dir it runs in (before a cd in it takes effect).
327
- def write_targets(words, walk)
328
- targets = []
329
- words.each_with_index do |word, i|
330
- if REDIRECTS.include?(word) && words[i + 1]
331
- targets << walk.path(words[i + 1])
332
- elsif (target = word[REDIRECT_WORD, 1]) && !word.start_with?("<")
333
- targets << walk.path(target)
334
- end
501
+ def link_command?(words)
502
+ at = program_at(words)
503
+ program = at && File.basename(words[at])
504
+ return true if LINKERS.include?(program)
505
+ return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
506
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
507
+
508
+ runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
509
+ name = File.basename(words[runner])
510
+ scripts(words, runner).any? do |script|
511
+ SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
335
512
  end
336
- at = program_at(words) or return targets
337
- program = File.basename(words[at])
338
- args = words[(at + 1)..].reject { |w| REDIRECTS.include?(w) || w.match?(REDIRECT_WORD) }
339
- plain = args.reject { |arg| arg.start_with?("-") }
340
- if program == "tee"
341
- targets.concat(plain.map { |arg| walk.path(arg) })
342
- elsif COPIERS.include?(program)
343
- dir = args[args.index("-t") + 1] if args.include?("-t")
344
- dir ||= args.find { |arg| arg.start_with?("--target-directory=") }&.split("=", 2)&.last
345
- dir ||= plain.last if plain.size >= 2
346
- targets << walk.path(dir) if dir
513
+ end
514
+
515
+ def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
516
+ depth: @depth + 1, web: @web)
517
+
518
+ # Whether it only reads but for writes (a > or >> target, cp's
519
+ # destination, tee's files) into the project or a tmp dir.
520
+ def writes_inside?
521
+ ReadOnlyShell.read_only_but_writes?(@text, cwd: @cwd, home: @home, env: ENV) { |path| writable?(path) }
522
+ end
523
+
524
+ # Whether it stays inside the project: every simple command runs in
525
+ # it, and every path it names lands in it or a tmp dir, a script it
526
+ # hands on included (a shell's read as a command, another
527
+ # interpreter's paths by its text). The program's own word is not a
528
+ # path it writes; a word it can't resolve ($var, a $(…) other than a
529
+ # message's heredoc, a dir it can't tell) is outside. A script file's
530
+ # body (ruby x.rb, make) is not read.
531
+ def stays_inside?
532
+ return false if @root.empty? || @depth > MAX_DEPTH
533
+
534
+ tokens = ShellLex.lex(@text)
535
+ return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
536
+
537
+ walk = ShellPaths::Walk.new(@cwd, @home, ENV)
538
+ ShellLex.simple_commands(tokens).all? do |words|
539
+ next walk.step(words) if words.is_a?(Symbol)
540
+
541
+ walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
542
+ scripts_inside?(words).tap { walk.step(words) }
543
+ end
544
+ end
545
+
546
+ # Whether every $(…) is a message's heredoc (its body cut as data).
547
+ def substs_messages_only?
548
+ rest = ShellLex.code_text(@text, keep_written: false).gsub(MESSAGE_SUBST, "")
549
+ !rest.include?("$(") && !rest.include?("`")
550
+ end
551
+
552
+ # Whether each word of a simple command but the program (and the
553
+ # wrappers before it) lands in the project or a tmp dir.
554
+ def words_inside?(words, walk)
555
+ at = program_at(words)
556
+ # xargs runs its program on paths it reads from its input
557
+ return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
558
+
559
+ words.each_with_index.all? do |word, i|
560
+ next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
561
+ next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
562
+
563
+ word_inside?(walk, word)
347
564
  end
348
- targets
349
565
  end
350
566
 
351
- def outside?(path)
352
- return true if path.nil?
353
- return false if DEVICES.include?(path)
567
+ def word_inside?(walk, word)
568
+ return true if data?(word)
569
+ return substs_messages_only? if word.include?(ShellLex::SUBST)
570
+
571
+ candidate = walk.candidate(word) or return true
572
+ return text_inside?(walk, candidate) if candidate.match?(ShellPaths::SCRIPT)
573
+ return glob_inside?(walk, candidate) if candidate.match?(GLOB) && !candidate.include?("$")
574
+
575
+ paths = walk.paths(candidate)
576
+ !paths.empty? && paths.all? { |path| DEVICES.include?(path) || writable?(path) }
577
+ end
578
+
579
+ # A word with spaces or quotes is text (a message, a pattern) unless
580
+ # it starts like a path.
581
+ def text_inside?(walk, text)
582
+ return true unless text.match?(%r{\A(?:~|\.\.|/)})
583
+
584
+ expanded = expand_tilde(text)
585
+ return false if expanded.start_with?("~")
586
+
587
+ walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
588
+ end
354
589
 
355
- real = ProtectedPaths.real(path)
356
- return false if tmp_roots.any? { |tmp| ShellPaths.within?(real, tmp) }
357
- return true if @root.empty?
590
+ # A glob by its fixed part (the text before its first glob character).
591
+ def glob_inside?(walk, word)
592
+ fixed = word[/\A[^*?\[\]{}]*/]
593
+ return walk.dirs.all? { |dir| dir && writable?(dir) } if fixed.empty?
358
594
 
359
- !ShellPaths.within?(real, ProtectedPaths.real(@root))
595
+ paths = walk.paths(fixed)
596
+ !paths.empty? && paths.all? { |path| writable?(path) }
360
597
  end
361
598
 
362
- def tmp_roots = @tmp_roots ||= PairCommand.tmp_roots
599
+ # The scripts a simple command hands on (#scripts): a shell's as a
600
+ # command of its own, another interpreter's by the paths in its text.
601
+ def scripts_inside?(words)
602
+ limit = program_at(words) || (words.size - 1)
603
+ at = (0..limit).find { |i| runner?(words[i]) } or return true
604
+ program = File.basename(words[at])
605
+ scripts(words, at).all? do |script|
606
+ if SHELLS.include?(program) || program == "eval"
607
+ !nested(script).outside_writes?
608
+ else
609
+ script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(expand_tilde(path), @cwd || Dir.pwd)) }
610
+ end
611
+ end
612
+ end
613
+
614
+ def writable?(path) = PairCommand.writable?(path, root: @root, cwd: @cwd)
615
+
616
+ # +text+ with a leading ~ (alone or before a /) as the home folder.
617
+ def expand_tilde(text) = text.sub(%r{\A~(?=/|\z)}, @home)
363
618
  end
364
619
  end
365
620
  end
@@ -6,6 +6,8 @@ require_relative "../memory_paths"
6
6
  require_relative "../hooks/loader"
7
7
  require_relative "approval"
8
8
  require_relative "verdict"
9
+ require_relative "chi_dirs"
10
+ require_relative "path_resolve"
9
11
  require_relative "protected_paths"
10
12
 
11
13
  module Samagotchi
@@ -80,22 +82,18 @@ module Samagotchi
80
82
 
81
83
  dirs = chi_dirs
82
84
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
83
- return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
85
+ reals = paths.flat_map { |path| PathResolve.reals(path) }
86
+ return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
84
87
 
85
88
  command = fetch(facts, :command).to_s
86
89
  return false if command.empty?
87
90
 
88
- command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
91
+ command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(PathResolve.real(dir)) }
89
92
  end
90
93
 
91
- # chi's config dir, the hooks dir, the approval store's dir and the
92
- # installed bundles' dir.
93
- def chi_dirs
94
- hooks = begin Hooks::Loader.hooks_dir rescue nil end
95
- bundles = begin MemoryPaths.bundles_dir rescue nil end
96
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
97
- .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
98
- end
94
+ # chi's config dir, the hooks dir, the approval store's dir, the
95
+ # installed bundles' dir and attached context (ChiDirs.protected).
96
+ def chi_dirs = ChiDirs.protected
99
97
 
100
98
  # An approval: its kind says so, or it carries approval facts.
101
99
  def approval?(pending)