samagotchi 0.129.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +18 -9
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +60 -16
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +13 -4
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +20 -4
  14. data/lib/samagotchi/engine_peers.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  16. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  17. data/lib/samagotchi/guardrails/outside.rb +22 -20
  18. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  19. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  21. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  22. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  23. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  24. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  25. data/lib/samagotchi/guardrails/rules.rb +23 -5
  26. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  27. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  28. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  29. data/lib/samagotchi/guardrails/targets.rb +11 -13
  30. data/lib/samagotchi/remote_command.rb +13 -3
  31. data/lib/samagotchi/remotes.rb +3 -2
  32. data/lib/samagotchi/review_start.rb +3 -2
  33. data/lib/samagotchi/session_inbox.rb +17 -0
  34. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  35. data/lib/samagotchi/terminal_ui.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/builtins.rb +10 -1
  38. data/lib/samagotchi/tools/peers.rb +4 -0
  39. data/lib/samagotchi/tools/task_wait.rb +26 -1
  40. data/lib/samagotchi/turn_state.rb +23 -4
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +23 -18
  43. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  44. data/lib/samagotchi/web/server.rb +29 -4
  45. data/lib/samagotchi/worker.rb +9 -1
  46. metadata +3 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outside"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
5
  require_relative "shell_lex"
6
6
  require_relative "shell_paths"
7
7
  require_relative "shared_git"
@@ -88,8 +88,8 @@ module Samagotchi
88
88
  # repository (and not into root, which may sit inside the main one),
89
89
  # under chi's env or the call's env: (Targets#shell_envs).
90
90
  def names_other_checkout?(targets, root)
91
- real_root = ProtectedPaths.real(root)
92
- others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
91
+ real_root = PathResolve.real(root)
92
+ others = [*@worktrees.call, @common_dir.call].compact.map { |dir| PathResolve.real(dir) } - [real_root]
93
93
  return false if others.empty?
94
94
 
95
95
  commands = ShellLex.simple_commands(ShellLex.lex(targets.command.to_s))
@@ -99,8 +99,8 @@ module Samagotchi
99
99
  walk.step(words).any? do |_word, path|
100
100
  next false unless path
101
101
 
102
- ProtectedPaths.reals(path).any? do |real|
103
- !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
102
+ PathResolve.reals(path).any? do |real|
103
+ !PathResolve.under?(real, real_root) && others.any? { |dir| PathResolve.under?(real, dir) }
104
104
  end
105
105
  end
106
106
  end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "tmpdir"
5
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
6
5
  require_relative "../memory_bundle/index_sync"
7
6
 
8
7
  module Samagotchi
@@ -20,17 +19,18 @@ module Samagotchi
20
19
  # A path whose landing place resolving can't tell (.resolve: a dangling
21
20
  # symlink, a file with another hard link) is always outside.
22
21
  module Outside
23
- TMP_DIRS = %w[/tmp /var/tmp].freeze
22
+ TMP_DIRS = PathResolve::TMP_DIRS
24
23
 
25
24
  # @param path [String] absolute
26
25
  # @param root [String] the session's repo root (or cwd)
27
26
  def self.outside?(path, root:)
28
27
  reals = resolve(path) or return true
29
- session = ProtectedPaths.real(root)
28
+ session = PathResolve.real(root)
29
+ tmp = nil
30
30
  reals.any? do |real|
31
31
  next false if within?(real, session)
32
32
  next false if MemoryBundle::IndexSync.memory_scope(path)
33
- next false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
33
+ next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
34
34
 
35
35
  true
36
36
  end
@@ -44,7 +44,7 @@ module Samagotchi
44
44
  # @param root [String] the session's project root
45
45
  def self.inside?(path, root:)
46
46
  reals = resolve(path) or return false
47
- session = ProtectedPaths.real(root)
47
+ session = PathResolve.real(root)
48
48
  reals.all? { |real| within?(real, session) }
49
49
  end
50
50
 
@@ -55,15 +55,16 @@ module Samagotchi
55
55
  # @param root [String] the session's repo root (or cwd)
56
56
  def self.tmp_write?(path, root:)
57
57
  reals = resolve(path) or return false
58
- session = ProtectedPaths.real(root)
59
- reals.all? { |real| tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) } }
58
+ session = PathResolve.real(root)
59
+ tmp = session_tmp_roots(session)
60
+ reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
60
61
  end
61
62
 
62
- # +path+ resolved (ProtectedPaths.reals: the lexical answer and where
63
+ # +path+ resolved (PathResolve.reals: the lexical answer and where
63
64
  # the kernel lands, a `..` after a symlinked dir climbing out of its
64
65
  # target), or nil when that doesn't say where a write lands: the path
65
66
  # is or goes through a dangling symlink (a write would create its
66
- # target; ProtectedPaths.real follows it, the lexical answer stops at
67
+ # target; PathResolve.real follows it, the lexical answer stops at
67
68
  # it, and either way the link's own place doesn't count as where the
68
69
  # file is), or is a file with another hard link (a write would change
69
70
  # that file too). Both outside? and tmp_write? read it, so neither
@@ -71,7 +72,7 @@ module Samagotchi
71
72
  # @param path [String] absolute
72
73
  # @return [Array<String>, nil]
73
74
  def self.resolve(path)
74
- reals = ProtectedPaths.reals(path)
75
+ reals = PathResolve.reals(path)
75
76
  return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
76
77
  return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
77
78
 
@@ -80,16 +81,17 @@ module Samagotchi
80
81
  nil
81
82
  end
82
83
 
83
- # The tmp dirs that exist, resolved.
84
- def self.tmp_roots
85
- [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
86
- File.realpath(dir) if File.directory?(dir)
87
- rescue SystemCallError
88
- nil
89
- end.uniq
90
- end
84
+ # The tmp dirs that exist, resolved (PathResolve.tmp_roots).
85
+ def self.tmp_roots = PathResolve.tmp_roots
86
+
87
+ # The tmp dirs +session+ (resolved) isn't in (PathResolve.tmp_roots).
88
+ def self.session_tmp_roots(session) = PathResolve.tmp_roots(session: session, roots: tmp_roots)
91
89
 
92
- def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
90
+ # Whether +path+ is +root+ or in it (PathResolve.under?): cased alike
91
+ # on a disk that folds case when +root+ exists (resolved, it has its
92
+ # on-disk case), by their exact text when it doesn't (a missing root
93
+ # keeps the case it was typed in).
94
+ def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
93
95
  end
94
96
  end
95
97
  end
@@ -25,7 +25,9 @@ module Samagotchi
25
25
  # read or written (read, write, edit, a plugin's targets: paths), and
26
26
  # on what a command does (execute, and any call whose targets carry
27
27
  # a command: PairCommand, which also refuses chi and chi web on a
28
- # loopback name, background and scheduled jobs, the environment and
28
+ # loopback name, URL openers (open, xdg-open, a browser) and
29
+ # listeners (nc -l, socat …LISTEN, a static HTTP server), background
30
+ # and scheduled jobs, the environment and
29
31
  # credential printers, git settings that run commands, home sweeps
30
32
  # and links to them, and a program named by a $var or a glob); a
31
33
  # write or edit of git's hooks (GIT_HOOKS);
@@ -78,8 +80,17 @@ module Samagotchi
78
80
  GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
79
81
  "(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
80
82
  GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
81
- CHI_WEB = "a participant's turn can't call chi web on this machine: on loopback it answers as the owner " \
82
- "(sessions, shares, answers)"
83
+ # chi web on loopback asks the owner's token too (Web::App#token_gate,
84
+ # LAN mode); this text check stays as defense in depth: a loopback-only
85
+ # chi web still answers loopback as the owner.
86
+ CHI_WEB = "a participant's turn can't call chi web on this machine: it is the owner's (sessions, shares, answers)"
87
+ # A page opened in the owner's browser carries the owner's cookies
88
+ # (chi web's on 127.0.0.1 or the LAN address among them) to whatever
89
+ # it points at; a port listening here could catch them.
90
+ OPENER = "a participant's turn can't open a URL or a file in the owner's apps: a browser sends that page the " \
91
+ "owner's cookies"
92
+ LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
93
+ "owner's browser cookies"
83
94
  VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
84
95
  UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
85
96
  "with the write tool"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outside"
4
+ require_relative "path_resolve"
4
5
  require_relative "read_only_shell"
5
6
  require_relative "review_paths"
6
7
  require_relative "shell_paths"
@@ -11,7 +12,8 @@ module Samagotchi
11
12
  # One shell command of a participant's pair turn, read the way
12
13
  # PairBoundary needs it (the refusal words are PairBoundary's):
13
14
  # - #refusal: why it may not run, nil when it may: chi, something left
14
- # 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
15
17
  # commands, a folder sweep, a secret (a path a word resolves to, a glob
16
18
  # or a $var whose fixed part leads into one, SECRET_TEXT for the
17
19
  # rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
@@ -66,6 +68,24 @@ module Samagotchi
66
68
  # A credential helper run by itself (docker-credential-osxkeychain get).
67
69
  CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
68
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/
69
89
  # This machine's loopback names (.web_text): localhost and its
70
90
  # subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
71
91
  LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
@@ -89,6 +109,9 @@ module Samagotchi
89
109
  GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
90
110
  # Devices a command may name in the project's stead.
91
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
+
92
115
  # A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
93
116
  # or an absolute path, at the text's start or after a space, quote,
94
117
  # bracket, = or comma.
@@ -120,7 +143,7 @@ module Samagotchi
120
143
  # (ShellPaths::HOOKS_PART): git runs what is there.
121
144
  def self.git_hook?(path)
122
145
  path.to_s.match?(ShellPaths::HOOKS_PART) ||
123
- (path.to_s.start_with?("/") && ProtectedPaths.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
146
+ (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
124
147
  end
125
148
 
126
149
  # A command's text naming chi web's endpoint on this machine: a
@@ -236,10 +259,34 @@ module Samagotchi
236
259
  return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
237
260
  return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
238
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)
239
264
 
240
265
  nil
241
266
  end
242
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
+
243
290
  # A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
244
291
  # ${e}i); the test brackets and braces are words of their own.
245
292
  def unplain_program?(word)
@@ -297,6 +344,8 @@ module Samagotchi
297
344
  return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
298
345
  return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
299
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)
300
349
 
301
350
  nil
302
351
  end
@@ -413,12 +462,16 @@ module Samagotchi
413
462
 
414
463
  # A word Walk can't resolve: a glob checked by what it matches and by
415
464
  # its fixed part, a $var by its fixed part (the text before the first
416
- # glob character or $, with ~ and $HOME expanded). A script body
417
- # (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.
418
471
  def unresolved_secret?(word)
419
472
  return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
420
473
 
421
- expanded = word.sub(%r{\A~(?=/|\z)}, @home).sub(/\A\$(?:\{HOME\}|HOME\b)/, @home)
474
+ expanded = PathResolve.expand_head(word, home: @home, env: ENV)
422
475
  fixed = expanded[/\A[^*?\[\]{}$]*/]
423
476
  dir = @cwd || Dir.pwd
424
477
  if !expanded.include?("$") && expanded.match?(GLOB)
@@ -427,18 +480,22 @@ module Samagotchi
427
480
  end
428
481
  return false if fixed.empty?
429
482
 
430
- 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)
431
485
  end
432
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
+
433
490
  # Whether a path's text is the start of a denied root's (its parent,
434
491
  # or part of its name: ~/.ss) or inside one.
435
492
  def leads_into_denied?(prefix)
436
- @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) }
437
494
  end
438
495
 
439
496
  # Whether +path+ is a denied root's folder or one above it.
440
497
  def above_denied?(path)
441
- ProtectedPaths.reals(path).any? { |real| @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) } }
498
+ PathResolve.reals(path).any? { |real| @paths.denied_roots.any? { |root| PathResolve.under?(root, real) } }
442
499
  end
443
500
 
444
501
  def link_command?(words)
@@ -524,7 +581,7 @@ module Samagotchi
524
581
  def text_inside?(walk, text)
525
582
  return true unless text.match?(%r{\A(?:~|\.\.|/)})
526
583
 
527
- expanded = text.sub(%r{\A~(?=/|\z)}, @home)
584
+ expanded = expand_tilde(text)
528
585
  return false if expanded.start_with?("~")
529
586
 
530
587
  walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
@@ -549,12 +606,15 @@ module Samagotchi
549
606
  if SHELLS.include?(program) || program == "eval"
550
607
  !nested(script).outside_writes?
551
608
  else
552
- script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(path.sub(%r{\A~(?=/|\z)}, @home), @cwd || Dir.pwd)) }
609
+ script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(expand_tilde(path), @cwd || Dir.pwd)) }
553
610
  end
554
611
  end
555
612
  end
556
613
 
557
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)
558
618
  end
559
619
  end
560
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,23 +82,18 @@ module Samagotchi
80
82
 
81
83
  dirs = chi_dirs
82
84
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
83
- reals = paths.flat_map { |path| ProtectedPaths.reals(path) }
84
- return true if reals.any? { |real| dirs.any? { |dir| ProtectedPaths.within?(real, dir) } }
85
+ reals = paths.flat_map { |path| PathResolve.reals(path) }
86
+ return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
85
87
 
86
88
  command = fetch(facts, :command).to_s
87
89
  return false if command.empty?
88
90
 
89
- 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)) }
90
92
  end
91
93
 
92
- # chi's config dir, the hooks dir, the approval store's dir and the
93
- # installed bundles' dir.
94
- def chi_dirs
95
- hooks = begin Hooks::Loader.hooks_dir rescue nil end
96
- bundles = begin MemoryPaths.bundles_dir rescue nil end
97
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
98
- .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
99
- 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
100
97
 
101
98
  # An approval: its kind says so, or it carries approval facts.
102
99
  def approval?(pending)
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # The guardrails' path primitives: how a path is expanded, resolved and
8
+ # compared, shared by every surface that judges one (ProtectedPaths,
9
+ # Outside, ShellPaths, RmTargets, ReviewPaths, PairCommand, Targets,
10
+ # ParentApprovals, System 1's SelfProtect). Each surface keeps its own
11
+ # predicate; this only says where a path lands and whether it is in a
12
+ # dir. docs/internals/tool-guardrails.md maps which surface uses which.
13
+ module PathResolve
14
+ # How many symlinks .real follows on a path that doesn't exist
15
+ # (about the kernel's own ELOOP limit).
16
+ MAX_LINKS = 40
17
+
18
+ # $HOME, ${HOME} and the XDG dirs at a word's start (.expand_word).
19
+ EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
20
+ # Where XDG dirs default when unset.
21
+ XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
22
+ "XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
23
+
24
+ # The tmp dirs besides Dir.tmpdir (.tmp_roots).
25
+ TMP_DIRS = %w[/tmp /var/tmp].freeze
26
+
27
+ # A dir's disk answer (.folds_case?), asked once per dir.
28
+ FOLDS = {}
29
+ FOLDS_MUTEX = Mutex.new
30
+
31
+ module_function
32
+
33
+ # +path+ as an absolute path the way the kernel opens it: ~ expanded,
34
+ # relative to +base+, and each `..` taken from where the dir before it
35
+ # really is (a `..` after a symlinked dir climbs out of its target,
36
+ # not back to the link's parent as File.expand_path has it). It
37
+ # differs from File.expand_path only for such a climb; the parts
38
+ # after the last `..` are kept as given (symlinks not resolved).
39
+ # A shell's cd may land at either (ShellPaths::Walk keeps both); a file
40
+ # argument and a tool's path go through this.
41
+ # @param path [String]
42
+ # @param base [String, nil] for a relative path (Dir.pwd when nil)
43
+ def expand(path, base = nil)
44
+ path = path.to_s
45
+ head, sep, tail = path.partition("/")
46
+ path = "#{File.expand_path(head)}#{sep}#{tail}" if head.start_with?("~")
47
+ path = File.join(File.expand_path(base || Dir.pwd), path) unless path.start_with?("/")
48
+ parts = []
49
+ path.split("/").each do |part|
50
+ next if part.empty? || part == "."
51
+
52
+ part == ".." ? parts = climb(parts) : parts << part
53
+ end
54
+ "/#{parts.join("/")}"
55
+ end
56
+
57
+ # The parts of +parts+'s parent dir: the lexical one unless the dir
58
+ # is (or goes through) a symlink whose target's parent is elsewhere.
59
+ def climb(parts)
60
+ lexical = parts[0...-1]
61
+ dir = "/#{parts.join("/")}"
62
+ return lexical unless parts.any? && File.directory?(dir)
63
+
64
+ physical = File.dirname(File.realpath(dir))
65
+ return lexical if File.realpath("/#{lexical.join("/")}") == physical
66
+
67
+ physical.split("/").reject(&:empty?)
68
+ rescue SystemCallError
69
+ lexical
70
+ end
71
+
72
+ # +path+ (.expand) with symlinks resolved on its longest existing
73
+ # parent: where the kernel lands for it. A part that is a dangling
74
+ # symlink is followed to its target (a write there creates the
75
+ # target), up to MAX_LINKS times; the missing tail is kept as given.
76
+ def real(path)
77
+ path = expand(path)
78
+ MAX_LINKS.times do
79
+ resolved = existing_prefix_real(path)
80
+ return resolved unless resolved.is_a?(Array)
81
+
82
+ path = resolved.first
83
+ end
84
+ path
85
+ rescue SystemCallError
86
+ path
87
+ end
88
+
89
+ # The old, lexical answer: File.expand_path (lexical `..`), then
90
+ # realpath of the longest existing prefix, stopping at a dangling
91
+ # symlink. .reals keeps it beside .real.
92
+ def lexical_real(path)
93
+ path = File.expand_path(path)
94
+ rest = []
95
+ current = path
96
+ until File.exist?(current) || current == File.dirname(current)
97
+ rest.unshift(File.basename(current))
98
+ current = File.dirname(current)
99
+ end
100
+ File.join(File.realpath(current), *rest)
101
+ rescue SystemCallError
102
+ path
103
+ end
104
+
105
+ # Every place +path+ may be judged as: .lexical_real (what these
106
+ # checks always looked at) and .real (where the kernel lands), one
107
+ # entry when they agree. A check that holds for any of them (a
108
+ # protected dir, a secret) or refuses unless all of them are inside
109
+ # (the project, tmp) refuses at least what it did with the lexical
110
+ # answer alone.
111
+ # @return [Array<String>]
112
+ def reals(path) = [lexical_real(path), real(path)].uniq
113
+
114
+ # realpath of +path+'s longest existing prefix with the rest
115
+ # appended (a String), or [the path with the first dangling symlink
116
+ # on the way replaced by its target] to resolve again.
117
+ def existing_prefix_real(path)
118
+ rest = []
119
+ current = path
120
+ until File.exist?(current) || current == File.dirname(current)
121
+ if File.symlink?(current)
122
+ target = expand(File.readlink(current), File.dirname(current))
123
+ return [expand(File.join(target, *rest))]
124
+ end
125
+ rest.unshift(File.basename(current))
126
+ current = File.dirname(current)
127
+ end
128
+ File.join(File.realpath(current), *rest)
129
+ end
130
+ private_class_method :climb, :existing_prefix_real
131
+
132
+ # Whether +path+ (resolved) is +root+ or in it, +root+ resolved
133
+ # either way (.reals).
134
+ def within?(path, root)
135
+ return false if root.nil? || root.to_s.empty?
136
+
137
+ reals(root.to_s.chomp("/")).any? { |dir| under?(path, dir) }
138
+ end
139
+
140
+ # Whether +path+ is +dir+ or in it (both absolute, resolved): by their
141
+ # text, or on a disk that folds case (macOS) by their text cased alike.
142
+ # realpath gives an existing part its on-disk case, but a missing
143
+ # tail keeps the case it was typed in (<state>/CONTEXT/x before
144
+ # <state>/context exists), and the kernel will create it in the
145
+ # folder that later answers to either. With +fold+ false, by their
146
+ # text alone.
147
+ def under?(path, dir, fold: true)
148
+ return true if path == dir || path.start_with?(File.join(dir, ""))
149
+ return false unless fold
150
+
151
+ low = path.downcase
152
+ low_dir = dir.downcase
153
+ (low == low_dir || low.start_with?(File.join(low_dir, ""))) && folds_case?(dir)
154
+ end
155
+
156
+ # Whether +path+ is in +dir+, not +dir+ itself (.under?'s terms).
157
+ def strictly_under?(path, dir, fold: true)
158
+ return true if path.start_with?(File.join(dir, ""))
159
+ return false unless fold
160
+
161
+ path.downcase.start_with?(File.join(dir.downcase, "")) && folds_case?(dir)
162
+ end
163
+
164
+ # The tmp dirs that exist (Dir.tmpdir, /tmp, /var/tmp), resolved; read
165
+ # each time (specs and a call's env: change TMPDIR). With +session+
166
+ # (a resolved session root) only those it isn't in, by their text
167
+ # (exact case): a session rooted in tmp gets no tmp dir of its own.
168
+ # @param roots [Array<String>, nil] the tmp dirs to filter (these by
169
+ # default)
170
+ def tmp_roots(session: nil, roots: nil)
171
+ roots ||= [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
172
+ File.realpath(dir) if File.directory?(dir)
173
+ rescue SystemCallError
174
+ nil
175
+ end.uniq
176
+ session ? roots.reject { |tmp| under?(session, tmp, fold: false) } : roots
177
+ end
178
+
179
+ # A shell word with a leading ~, $HOME/${HOME}, an XDG dir or $TMPDIR
180
+ # expanded (+env+'s value, an unset XDG dir its default under +home+);
181
+ # nil when a $ or ~ is left. ShellPaths::Walk (cd and file words) and
182
+ # ShellGitDirs (git -C and the like) expand this way.
183
+ def expand_word(word, home:, env:)
184
+ word = expand_head(word, home: home, env: env)
185
+ return nil if word.include?("$") || word.start_with?("~")
186
+
187
+ word
188
+ end
189
+
190
+ # +word+ with its leading ~, $HOME/${HOME}, XDG dir or $TMPDIR
191
+ # expanded as .expand_word does, the rest kept as it is ($ and globs
192
+ # included); an unset variable without a default leaves a "$".
193
+ def expand_head(word, home:, env:)
194
+ word = word.sub(%r{\A~(?=/|\z)}) { home }
195
+ word.sub(EXPANSION) { variable(Regexp.last_match(1) || Regexp.last_match(2), home, env) || "$" }
196
+ end
197
+
198
+ def variable(name, home, env)
199
+ return home if name == "HOME"
200
+
201
+ set = env[name].to_s
202
+ return set unless set.empty?
203
+
204
+ XDG_DEFAULTS[name] && File.join(home, XDG_DEFAULTS[name])
205
+ end
206
+ private_class_method :variable
207
+
208
+ # Whether the disk +path+'s deepest existing dir is on folds case: that
209
+ # dir (or the first one above it with a letter in its name) answers to
210
+ # its name with the case swapped. Asked once per dir. A relative or
211
+ # blank path has no disk to ask: false.
212
+ def folds_case?(path)
213
+ return false unless path.to_s.start_with?("/")
214
+
215
+ probe = path
216
+ probe = File.dirname(probe) until probe == "/" || File.directory?(probe)
217
+ probe = File.dirname(probe) until probe == "/" || File.basename(probe).swapcase != File.basename(probe)
218
+ return false if probe == "/"
219
+
220
+ FOLDS_MUTEX.synchronize do
221
+ FOLDS.fetch(probe) do
222
+ other = File.join(File.dirname(probe), File.basename(probe).swapcase)
223
+ FOLDS[probe] = File.exist?(other) && File.identical?(probe, other)
224
+ end
225
+ end
226
+ rescue SystemCallError
227
+ false
228
+ end
229
+ end
230
+ end
231
+ end