samagotchi 0.128.0 → 0.129.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/docs/architecture.md +12 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +150 -25
  6. data/docs/internals/participants.md +17 -3
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge_client.rb +8 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  12. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  13. data/lib/samagotchi/guardrails/outside.rb +54 -5
  14. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  15. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  16. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  17. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  18. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  19. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  20. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  21. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  22. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  23. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  24. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  25. data/lib/samagotchi/guardrails/targets.rb +128 -24
  26. data/lib/samagotchi/session_manager.rb +9 -15
  27. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  28. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  29. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  30. data/lib/samagotchi/version.rb +1 -1
  31. data/lib/samagotchi/web/app.rb +9 -7
  32. data/lib/samagotchi/web/public/app.js +8 -1
  33. data/lib/samagotchi/web/public/participant.js +2 -0
  34. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  35. data/lib/samagotchi/worker.rb +82 -32
  36. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  37. data/lib/samagotchi/worker_link.rb +13 -5
  38. metadata +1 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tmpdir"
3
+ require_relative "outside"
4
+ require_relative "read_only_shell"
4
5
  require_relative "review_paths"
5
6
  require_relative "shell_paths"
6
7
  require_relative "shell_lex"
@@ -17,10 +18,15 @@ module Samagotchi
17
18
  # a command of its own, one it hands another interpreter (ruby -e,
18
19
  # python <<EOF) by its text. A command it can't read is refused
19
20
  # (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.
21
+ # - #outside_writes?: whether it may change something outside the
22
+ # project, failing closed: no unless it only reads
23
+ # (ReadOnlyShell.read_only?), or only reads but for writes into the
24
+ # project or a tmp dir (ReadOnlyShell.read_only_but_writes?), or
25
+ # stays inside the project (#stays_inside?: it runs in the project and
26
+ # every path it names, an option's value, a glob's fixed part and the
27
+ # paths in a script it hands on among them, lands in the project or a
28
+ # tmp dir, Outside.inside? / Outside.tmp_write?). A word it can't
29
+ # resolve ($var, $(…), a dir after cd $X) counts as outside.
24
30
  # A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
25
31
  # is not looked into: it's text being written, not run.
26
32
  class PairCommand
@@ -37,45 +43,119 @@ module Samagotchi
37
43
  INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
38
44
  # Options whose next word is code or a command, for any program.
39
45
  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
46
+ # Programs that detach what they start, or run it later (cron, at).
47
+ DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
42
48
  # Programs that print the environment or a keychain's secrets, whatever
43
49
  # their arguments.
44
- ENV_READERS = %w[printenv security].freeze
50
+ ENV_READERS = %w[printenv security op].freeze
51
+ # Commands that print a stored credential (a token, a password, a
52
+ # key), by the program and the words that say so in its arguments.
53
+ CREDENTIAL_PRINTERS = {
54
+ "gh" => [%w[auth token]],
55
+ "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
56
+ %w[credential-manager]],
57
+ "gcloud" => [%w[print-access-token], %w[print-identity-token]],
58
+ "aws" => [%w[configure get], %w[configure export-credentials], %w[ecr get-login-password],
59
+ %w[sts get-session-token]],
60
+ "az" => [%w[account get-access-token]],
61
+ "heroku" => [%w[auth:token]],
62
+ "npm" => [%w[token]],
63
+ "kubectl" => [%w[config view --raw]],
64
+ "keyring" => [%w[get]]
65
+ }.freeze
66
+ # A credential helper run by itself (docker-credential-osxkeychain get).
67
+ CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
45
68
  ENV_TEXT = %r{/proc/[^\s]*/environ}
69
+ # This machine's loopback names (.web_text): localhost and its
70
+ # subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
71
+ LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
72
+ /\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
46
73
  # An interpreter's script that reads the environment or detaches.
47
74
  SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
48
75
  SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
49
76
  # Programs that read a folder's whole tree: given home (or any folder
50
77
  # 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
78
+ # A link to such a folder reaches them too (ln, link).
79
+ SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto ln link].freeze
80
+ # Programs that make a link (and cp's options that do).
81
+ LINKERS = %w[ln link mklink].freeze
82
+ CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
83
+ # An interpreter's script that makes a link.
84
+ SCRIPT_LINK_TEXT = /symlink|\blink\(|\bln\s|\bmklink\b|FileUtils\.ln|hardlink/i
52
85
  # git settings that make git run a command (or show one), and git
53
86
  # config's scopes beyond the repository.
54
87
  GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
55
88
  GIT_WIDE_SCOPES = %w[--global --system].freeze
56
89
  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
90
+ # Devices a command may name in the project's stead.
91
+ DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
92
+ # A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
93
+ # or an absolute path, at the text's start or after a space, quote,
94
+ # bracket, = or comma.
95
+ SCRIPT_PATH = %r{(?:\A|(?<=[\s'"(\[=,]))(?:~|\.\.|/[\w.~-])[^\s'"`;,()\]]*}
96
+ # A $(…) that is only a message's heredoc (git commit -m "$(cat <<EOF …)"),
97
+ # its body cut by ShellLex.code_text.
98
+ MESSAGE_SUBST = /\$\(cat <<-?(['"]?)(\w+)\1\n\2\n\)/
99
+ # Assignments whose value is a search list, not a path.
100
+ LIST_VARIABLES = %w[PATH].freeze
62
101
  GLOB = /[*?\[\]{}]/
102
+ SHELL_BRACKETS = %w[[ [[ ]] { } ]].freeze
63
103
  # How many matches of a glob are checked against the denylist.
64
104
  GLOB_LIMIT = 500
65
105
  # How deep scripts handed to a shell are read; deeper is UNREADABLE.
66
106
  MAX_DEPTH = 3
67
107
 
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
108
+ # Whether a pair turn may write +path+ (absolute) without asking: it
109
+ # lands in the project (Outside.inside?) or in a tmp dir the project
110
+ # isn't in (Outside.tmp_write?), every way it resolves. No project
111
+ # (+root+ blank): the tmp dirs only.
112
+ def self.writable?(path, root:, cwd: nil)
113
+ root = root.to_s
114
+ return Outside.tmp_write?(path, root: cwd || Dir.pwd) if root.empty?
115
+
116
+ Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
117
+ end
118
+
119
+ # Whether +path+ is in a .git/hooks folder, as given or as it resolves
120
+ # (ShellPaths::HOOKS_PART): git runs what is there.
121
+ def self.git_hook?(path)
122
+ path.to_s.match?(ShellPaths::HOOKS_PART) ||
123
+ (path.to_s.start_with?("/") && ProtectedPaths.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
124
+ end
125
+
126
+ # A command's text naming chi web's endpoint on this machine: a
127
+ # loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
128
+ # +ports+, anywhere in it (a URL's host:port, nc's host port, a
129
+ # script's ("host", port), /dev/tcp/host/port, curl's --resolve); or
130
+ # 0:port. nil without a port.
131
+ # @return [Regexp, nil]
132
+ def self.web_text(ports, hostnames: [])
133
+ return nil if ports.empty?
134
+
135
+ hosts = (LOOPBACK_HOSTS.map(&:source) + hostnames.map { |name| Regexp.escape(name) }).join("|")
136
+ port = ports.map { |number| Regexp.escape(number.to_s) }.join("|")
137
+ Regexp.new("\\A(?=[\\s\\S]*?(?<![\\w.-])(?:#{hosts})(?![\\w-]))(?=[\\s\\S]*?(?<![\\w.])(?:#{port})(?!\\d))|" \
138
+ "(?<![\\w.:-])0:(?:#{port})(?!\\d)", Regexp::IGNORECASE)
139
+ end
140
+
141
+ # This machine's own names (Socket.gethostname, with and without
142
+ # its domain and with .local), lowercased.
143
+ def self.hostnames
144
+ require "socket"
145
+ name = Socket.gethostname.to_s.downcase
146
+ short = name.split(".").first.to_s
147
+ [name, short, "#{short}.local"].reject { |host| host.empty? || host == ".local" }.uniq
148
+ rescue StandardError
149
+ []
73
150
  end
74
151
 
75
152
  # @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
153
+ # @param root [String, nil] the project root (outside writes; none:
154
+ # every command but a read-only one writes outside)
155
+ # @param web [Regexp, nil] chi web's endpoint in a command's text
156
+ # (.web_text)
157
+ def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, web: nil)
158
+ @web = web
79
159
  @text = text.to_s
80
160
  @cwd = cwd
81
161
  @paths = paths
@@ -93,10 +173,28 @@ module Samagotchi
93
173
  @refusal = PairBoundary::UNREADABLE
94
174
  end
95
175
 
96
- # Whether it writes outside the project (asked about, never denied).
176
+ # Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
177
+ # path through a link it makes now is judged before the link exists,
178
+ # so the owner approves it (asked about; a link to a folder above a
179
+ # secret is refused: SWEEPERS). True for one it can't read.
180
+ def links?
181
+ return @links if defined?(@links)
182
+
183
+ @links = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
184
+ words.is_a?(Array) && link_command?(words)
185
+ end
186
+ rescue StandardError
187
+ @links = true
188
+ end
189
+
190
+ # Whether it may write outside the project (asked about, never
191
+ # denied); true for one it can't read.
97
192
  def outside_writes?
98
- refusal
99
- @targets.to_a.any? { |path| outside?(path) }
193
+ return @outside_writes if defined?(@outside_writes)
194
+
195
+ @outside_writes = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
196
+ rescue StandardError
197
+ @outside_writes = true
100
198
  end
101
199
 
102
200
  private
@@ -110,14 +208,14 @@ module Samagotchi
110
208
  return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
111
209
  return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
112
210
  return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
211
+ return PairBoundary::CHI_WEB if @web && code.match?(@web)
113
212
  return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
114
213
 
115
- @targets = []
214
+ @read_only = ReadOnlyShell.read_only?(@text)
116
215
  walk = ShellPaths::Walk.new(@cwd, @home, ENV)
117
216
  ShellLex.simple_commands(tokens).each do |words|
118
217
  next walk.step(words) if words.is_a?(Symbol)
119
218
 
120
- @targets.concat(write_targets(words, walk))
121
219
  reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
122
220
  return reason if reason
123
221
  end
@@ -127,10 +225,12 @@ module Samagotchi
127
225
  # A simple command's own refusal, by its program and arguments.
128
226
  def words_refusal(words)
129
227
  at = program_at(words)
228
+ return PairBoundary::CHI if chi_in_values?(words)
229
+ return PairBoundary::VERB if at && unplain_program?(words[at])
230
+
130
231
  program = at && File.basename(words[at])
131
232
  args = at ? words[(at + 1)..] : []
132
233
  return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
133
- return PairBoundary::CHI if chi_in_values?(words)
134
234
 
135
235
  upto = at ? words[0..at] : words
136
236
  return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
@@ -140,6 +240,14 @@ module Samagotchi
140
240
  nil
141
241
  end
142
242
 
243
+ # A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
244
+ # ${e}i); the test brackets and braces are words of their own.
245
+ def unplain_program?(word)
246
+ return false if SHELL_BRACKETS.include?(word)
247
+
248
+ word.include?("$") || word.include?(ShellLex::SUBST) || word.match?(GLOB)
249
+ end
250
+
143
251
  # chi in an assignment's value (FOO=chi, alias x='chi …') or in the
144
252
  # word after an option that takes code or a command (-exec, -c, -e).
145
253
  def chi_in_values?(words)
@@ -158,8 +266,7 @@ module Samagotchi
158
266
  program = File.basename(words[at])
159
267
  scripts(words, at).each do |script|
160
268
  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
269
+ nested(script).refusal
163
270
  elsif INTERPRETERS.include?(program)
164
271
  script_text_refusal(script)
165
272
  end
@@ -218,21 +325,32 @@ module Samagotchi
218
325
  (program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
219
326
  end
220
327
 
221
- # printenv, security; set, export, declare and typeset that list
222
- # (no assignment); launchctl getenv/export; ps with a BSD e (ps e, eww).
328
+ # printenv, security, op, a credential printer (CREDENTIAL_PRINTERS)
329
+ # or helper; set, export, declare and typeset that list (no
330
+ # assignment); launchctl getenv/export; ps with a BSD e (ps e, eww) or
331
+ # -E (ps -E, -axE).
223
332
  def env_reader?(program, args)
224
- return true if ENV_READERS.include?(program)
333
+ return true if ENV_READERS.include?(program) || program.to_s.match?(CREDENTIAL_HELPER)
334
+ return true if credential_printer?(program, args)
225
335
 
226
336
  listing = args.none? { |arg| !arg.start_with?("-", "+") }
227
337
  case program
228
338
  when "set" then args.empty?
229
339
  when "export", "declare", "typeset" then listing
230
340
  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/) }
341
+ 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
342
  else false
233
343
  end
234
344
  end
235
345
 
346
+ # Whether +args+ hold one of +program+'s CREDENTIAL_PRINTERS words, in
347
+ # order and next to each other (git -C x credential fill).
348
+ def credential_printer?(program, args)
349
+ CREDENTIAL_PRINTERS.fetch(program, []).any? do |words|
350
+ args.each_cons(words.size).any?(words)
351
+ end
352
+ end
353
+
236
354
  # env with no command after it prints the environment.
237
355
  def bare_env?(words)
238
356
  assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
@@ -279,6 +397,8 @@ module Samagotchi
279
397
  def paths_refusal(words, resolved)
280
398
  program_word = (at = program_at(words)) && File.basename(words[at])
281
399
  resolved.each do |word, path|
400
+ return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
401
+
282
402
  if path
283
403
  return PairBoundary::SECRET if @paths.secret_refusal([path])
284
404
  return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
@@ -318,48 +438,123 @@ module Samagotchi
318
438
 
319
439
  # Whether +path+ is a denied root's folder or one above it.
320
440
  def above_denied?(path)
321
- real = ProtectedPaths.real(path)
322
- @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
441
+ ProtectedPaths.reals(path).any? { |real| @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) } }
323
442
  end
324
443
 
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
444
+ def link_command?(words)
445
+ at = program_at(words)
446
+ program = at && File.basename(words[at])
447
+ return true if LINKERS.include?(program)
448
+ return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
449
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
450
+
451
+ runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
452
+ name = File.basename(words[runner])
453
+ scripts(words, runner).any? do |script|
454
+ SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
335
455
  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
456
+ end
457
+
458
+ def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
459
+ depth: @depth + 1, web: @web)
460
+
461
+ # Whether it only reads but for writes (a > or >> target, cp's
462
+ # destination, tee's files) into the project or a tmp dir.
463
+ def writes_inside?
464
+ ReadOnlyShell.read_only_but_writes?(@text, cwd: @cwd, home: @home, env: ENV) { |path| writable?(path) }
465
+ end
466
+
467
+ # Whether it stays inside the project: every simple command runs in
468
+ # it, and every path it names lands in it or a tmp dir, a script it
469
+ # hands on included (a shell's read as a command, another
470
+ # interpreter's paths by its text). The program's own word is not a
471
+ # path it writes; a word it can't resolve ($var, a $(…) other than a
472
+ # message's heredoc, a dir it can't tell) is outside. A script file's
473
+ # body (ruby x.rb, make) is not read.
474
+ def stays_inside?
475
+ return false if @root.empty? || @depth > MAX_DEPTH
476
+
477
+ tokens = ShellLex.lex(@text)
478
+ return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
479
+
480
+ walk = ShellPaths::Walk.new(@cwd, @home, ENV)
481
+ ShellLex.simple_commands(tokens).all? do |words|
482
+ next walk.step(words) if words.is_a?(Symbol)
483
+
484
+ walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
485
+ scripts_inside?(words).tap { walk.step(words) }
347
486
  end
348
- targets
349
487
  end
350
488
 
351
- def outside?(path)
352
- return true if path.nil?
353
- return false if DEVICES.include?(path)
489
+ # Whether every $(…) is a message's heredoc (its body cut as data).
490
+ def substs_messages_only?
491
+ rest = ShellLex.code_text(@text, keep_written: false).gsub(MESSAGE_SUBST, "")
492
+ !rest.include?("$(") && !rest.include?("`")
493
+ end
494
+
495
+ # Whether each word of a simple command but the program (and the
496
+ # wrappers before it) lands in the project or a tmp dir.
497
+ def words_inside?(words, walk)
498
+ at = program_at(words)
499
+ # xargs runs its program on paths it reads from its input
500
+ return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
501
+
502
+ words.each_with_index.all? do |word, i|
503
+ next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
504
+ next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
505
+
506
+ word_inside?(walk, word)
507
+ end
508
+ end
354
509
 
355
- real = ProtectedPaths.real(path)
356
- return false if tmp_roots.any? { |tmp| ShellPaths.within?(real, tmp) }
357
- return true if @root.empty?
510
+ def word_inside?(walk, word)
511
+ return true if data?(word)
512
+ return substs_messages_only? if word.include?(ShellLex::SUBST)
358
513
 
359
- !ShellPaths.within?(real, ProtectedPaths.real(@root))
514
+ candidate = walk.candidate(word) or return true
515
+ return text_inside?(walk, candidate) if candidate.match?(ShellPaths::SCRIPT)
516
+ return glob_inside?(walk, candidate) if candidate.match?(GLOB) && !candidate.include?("$")
517
+
518
+ paths = walk.paths(candidate)
519
+ !paths.empty? && paths.all? { |path| DEVICES.include?(path) || writable?(path) }
520
+ end
521
+
522
+ # A word with spaces or quotes is text (a message, a pattern) unless
523
+ # it starts like a path.
524
+ def text_inside?(walk, text)
525
+ return true unless text.match?(%r{\A(?:~|\.\.|/)})
526
+
527
+ expanded = text.sub(%r{\A~(?=/|\z)}, @home)
528
+ return false if expanded.start_with?("~")
529
+
530
+ walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
531
+ end
532
+
533
+ # A glob by its fixed part (the text before its first glob character).
534
+ def glob_inside?(walk, word)
535
+ fixed = word[/\A[^*?\[\]{}]*/]
536
+ return walk.dirs.all? { |dir| dir && writable?(dir) } if fixed.empty?
537
+
538
+ paths = walk.paths(fixed)
539
+ !paths.empty? && paths.all? { |path| writable?(path) }
540
+ end
541
+
542
+ # The scripts a simple command hands on (#scripts): a shell's as a
543
+ # command of its own, another interpreter's by the paths in its text.
544
+ def scripts_inside?(words)
545
+ limit = program_at(words) || (words.size - 1)
546
+ at = (0..limit).find { |i| runner?(words[i]) } or return true
547
+ program = File.basename(words[at])
548
+ scripts(words, at).all? do |script|
549
+ if SHELLS.include?(program) || program == "eval"
550
+ !nested(script).outside_writes?
551
+ else
552
+ script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(path.sub(%r{\A~(?=/|\z)}, @home), @cwd || Dir.pwd)) }
553
+ end
554
+ end
360
555
  end
361
556
 
362
- def tmp_roots = @tmp_roots ||= PairCommand.tmp_roots
557
+ def writable?(path) = PairCommand.writable?(path, root: @root, cwd: @cwd)
363
558
  end
364
559
  end
365
560
  end
@@ -80,7 +80,8 @@ module Samagotchi
80
80
 
81
81
  dirs = chi_dirs
82
82
  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) } }
83
+ reals = paths.flat_map { |path| ProtectedPaths.reals(path) }
84
+ return true if reals.any? { |real| dirs.any? { |dir| ProtectedPaths.within?(real, dir) } }
84
85
 
85
86
  command = fetch(facts, :command).to_s
86
87
  return false if command.empty?
@@ -4,7 +4,9 @@ module Samagotchi
4
4
  module Guardrails
5
5
  # Core checks on what the file tools (write, edit, memory_write) touch,
6
6
  # after the hooks and before the rules. Paths are compared with
7
- # symlinks resolved (on the longest existing parent).
7
+ # symlinks resolved (on the longest existing parent, `..` taken
8
+ # physically, a dangling symlink followed: .reals, the lexical answer
9
+ # and where the write lands, a vote when either is protected).
8
10
  # deny: the approval store's dir, installed bundles (.bundles/:
9
11
  # hooks, rules, manifests) and attached context (a source's
10
12
  # command runs later, outside the gate; chi context writes
@@ -41,7 +43,7 @@ module Samagotchi
41
43
  targets = verdict.targets
42
44
  return verdict unless targets && FILE_TOOLS.include?(targets.tool)
43
45
 
44
- paths = targets.paths.map { |p| self.class.real(p) }
46
+ paths = targets.paths.flat_map { |p| self.class.reals(p) }
45
47
  @deny.each do |rule, root, reason|
46
48
  next unless paths.any? { |p| self.class.within?(p, root) }
47
49
 
@@ -55,8 +57,71 @@ module Samagotchi
55
57
  verdict
56
58
  end
57
59
 
58
- # +path+ with symlinks resolved on its longest existing parent.
60
+ # +path+ as an absolute path the way the kernel opens it: ~ expanded,
61
+ # relative to +base+, and each `..` taken from where the dir before it
62
+ # really is (a `..` after a symlinked dir climbs out of its target,
63
+ # not back to the link's parent as File.expand_path has it). It
64
+ # differs from File.expand_path only for such a climb; the parts
65
+ # after the last `..` are kept as given (symlinks not resolved).
66
+ # A shell's cd may land at either (ShellPaths::Walk keeps both); a file
67
+ # argument and a tool's path go through this.
68
+ # @param path [String]
69
+ # @param base [String, nil] for a relative path (Dir.pwd when nil)
70
+ def self.expand(path, base = nil)
71
+ path = path.to_s
72
+ head, sep, tail = path.partition("/")
73
+ path = "#{File.expand_path(head)}#{sep}#{tail}" if head.start_with?("~")
74
+ path = File.join(File.expand_path(base || Dir.pwd), path) unless path.start_with?("/")
75
+ parts = []
76
+ path.split("/").each do |part|
77
+ next if part.empty? || part == "."
78
+
79
+ part == ".." ? parts = climb(parts) : parts << part
80
+ end
81
+ "/#{parts.join("/")}"
82
+ end
83
+
84
+ # The parts of +parts+'s parent dir: the lexical one unless the dir
85
+ # is (or goes through) a symlink whose target's parent is elsewhere.
86
+ def self.climb(parts)
87
+ lexical = parts[0...-1]
88
+ dir = "/#{parts.join("/")}"
89
+ return lexical unless parts.any? && File.directory?(dir)
90
+
91
+ physical = File.dirname(File.realpath(dir))
92
+ return lexical if File.realpath("/#{lexical.join("/")}") == physical
93
+
94
+ physical.split("/").reject(&:empty?)
95
+ rescue SystemCallError
96
+ lexical
97
+ end
98
+ private_class_method :climb
99
+
100
+ # How many symlinks .real follows on a path that doesn't exist
101
+ # (about the kernel's own ELOOP limit).
102
+ MAX_LINKS = 40
103
+
104
+ # +path+ (.expand) with symlinks resolved on its longest existing
105
+ # parent: where the kernel lands for it. A part that is a dangling
106
+ # symlink is followed to its target (a write there creates the
107
+ # target), up to MAX_LINKS times; the missing tail is kept as given.
59
108
  def self.real(path)
109
+ path = expand(path)
110
+ MAX_LINKS.times do
111
+ resolved = existing_prefix_real(path)
112
+ return resolved unless resolved.is_a?(Array)
113
+
114
+ path = resolved.first
115
+ end
116
+ path
117
+ rescue SystemCallError
118
+ path
119
+ end
120
+
121
+ # The old, lexical answer: File.expand_path (lexical `..`), then
122
+ # realpath of the longest existing prefix, stopping at a dangling
123
+ # symlink. .reals keeps it beside .real.
124
+ def self.lexical_real(path)
60
125
  path = File.expand_path(path)
61
126
  rest = []
62
127
  current = path
@@ -69,11 +134,39 @@ module Samagotchi
69
134
  path
70
135
  end
71
136
 
137
+ # Every place +path+ may be judged as: .lexical_real (what these
138
+ # checks always looked at) and .real (where the kernel lands), one
139
+ # entry when they agree. A check that holds for any of them (a
140
+ # protected dir, a secret) or refuses unless all of them are inside
141
+ # (the project, tmp) refuses at least what it did with the lexical
142
+ # answer alone.
143
+ # @return [Array<String>]
144
+ def self.reals(path) = [lexical_real(path), real(path)].uniq
145
+
146
+ # realpath of +path+'s longest existing prefix with the rest
147
+ # appended (a String), or [the path with the first dangling symlink
148
+ # on the way replaced by its target] to resolve again.
149
+ def self.existing_prefix_real(path)
150
+ rest = []
151
+ current = path
152
+ until File.exist?(current) || current == File.dirname(current)
153
+ if File.symlink?(current)
154
+ target = expand(File.readlink(current), File.dirname(current))
155
+ return [expand(File.join(target, *rest))]
156
+ end
157
+ rest.unshift(File.basename(current))
158
+ current = File.dirname(current)
159
+ end
160
+ File.join(File.realpath(current), *rest)
161
+ end
162
+ private_class_method :existing_prefix_real
163
+
164
+ # Whether +path+ (resolved) is +root+ or in it, +root+ resolved
165
+ # either way (.reals).
72
166
  def self.within?(path, root)
73
167
  return false if root.nil? || root.to_s.empty?
74
168
 
75
- root = real(root.to_s.chomp("/"))
76
- path == root || path.start_with?(File.join(root, ""))
169
+ reals(root.to_s.chomp("/")).any? { |dir| path == dir || path.start_with?(File.join(dir, "")) }
77
170
  end
78
171
  end
79
172
  end