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
@@ -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
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "path_resolve"
4
+
3
5
  module Samagotchi
4
6
  module Guardrails
5
7
  # Core checks on what the file tools (write, edit, memory_write) touch,
6
8
  # after the hooks and before the rules. Paths are compared with
7
- # symlinks resolved (on the longest existing parent).
9
+ # symlinks resolved (on the longest existing parent, `..` taken
10
+ # physically, a dangling symlink followed: PathResolve.reals, the
11
+ # lexical answer and where the write lands, a vote when either is
12
+ # protected).
8
13
  # deny: the approval store's dir, installed bundles (.bundles/:
9
14
  # hooks, rules, manifests) and attached context (a source's
10
15
  # command runs later, outside the gate; chi context writes
@@ -41,40 +46,29 @@ module Samagotchi
41
46
  targets = verdict.targets
42
47
  return verdict unless targets && FILE_TOOLS.include?(targets.tool)
43
48
 
44
- paths = targets.paths.map { |p| self.class.real(p) }
49
+ paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
45
50
  @deny.each do |rule, root, reason|
46
- next unless paths.any? { |p| self.class.within?(p, root) }
51
+ next unless paths.any? { |p| PathResolve.within?(p, root) }
47
52
 
48
53
  verdict.deny!(reason, rule: rule, source: SOURCE, decided_by: "core")
49
54
  end
50
55
  @ask.each do |rule, root, reason|
51
- next unless paths.any? { |p| self.class.within?(p, root) }
56
+ next unless paths.any? { |p| PathResolve.within?(p, root) }
52
57
 
53
58
  verdict.ask!(reason, scopes: %w[once session], rule: rule, source: SOURCE, decided_by: "core")
54
59
  end
55
60
  verdict
56
61
  end
57
62
 
58
- # +path+ with symlinks resolved on its longest existing parent.
59
- def self.real(path)
60
- path = File.expand_path(path)
61
- rest = []
62
- current = path
63
- until File.exist?(current) || current == File.dirname(current)
64
- rest.unshift(File.basename(current))
65
- current = File.dirname(current)
66
- end
67
- File.join(File.realpath(current), *rest)
68
- rescue SystemCallError
69
- path
70
- end
71
-
72
- def self.within?(path, root)
73
- return false if root.nil? || root.to_s.empty?
74
-
75
- root = real(root.to_s.chomp("/"))
76
- path == root || path.start_with?(File.join(root, ""))
77
- end
63
+ # The path primitives (PathResolve) as this class has long named
64
+ # them; specs and older callers reach them here.
65
+ def self.expand(path, base = nil) = PathResolve.expand(path, base)
66
+ def self.real(path) = PathResolve.real(path)
67
+ def self.lexical_real(path) = PathResolve.lexical_real(path)
68
+ def self.reals(path) = PathResolve.reals(path)
69
+ def self.within?(path, root) = PathResolve.within?(path, root)
70
+ def self.under?(path, dir) = PathResolve.under?(path, dir)
71
+ def self.folds_case?(path) = PathResolve.folds_case?(path)
78
72
  end
79
73
  end
80
74
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "strscan"
4
4
  require_relative "shell_lex"
5
+ require_relative "shell_paths"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -17,6 +18,17 @@ module Samagotchi
17
18
  # $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
18
19
  # expansion, a < or << redirection, a path to a verb (/bin/ls), and
19
20
  # less/more/vim (they run commands) are all not read-only.
21
+ #
22
+ # .read_only_but_writes? (the child boundary's) also lets through the
23
+ # writes it can name: a > or >> target, cp's destination and tee's
24
+ # files, each resolved against the dir the command is in by then
25
+ # (ShellPaths::Walk) and accepted by the caller's block. It trusts
26
+ # Walk's dir only where the shell's can't differ: a cd or pushd takes
27
+ # one existing dir, spelt /…, . or .. (no option, +N, -, $, ~ or
28
+ # CDPATH name), and joins beside it are && only (a failed or skipped
29
+ # cd can't be followed by a command that still runs); popd is never
30
+ # accepted, nor is a $ or ~ in a write's target (the call's env may
31
+ # set it).
20
32
  module ReadOnlyShell
21
33
  # Operators that join read-only commands (no &, no ;;).
22
34
  JOINS = ["&&", "||", ";", "|", "|&", "\n", "(", ")"].freeze
@@ -29,6 +41,49 @@ module Samagotchi
29
41
  # target follows).
30
42
  NULL_REDIRECT = %r{\A\d*>>?/dev/null\z}
31
43
  BARE_REDIRECT = /\A\d*>>?\z/
44
+ # A redirection with its target in the same word (>out, 2>>out); not
45
+ # >&file (it is &> spelt the other way).
46
+ REDIRECT_TO = /\A\d*>>?([^&>].*)\z/m
47
+ # Pipe operators: each side runs in a subshell.
48
+ PIPES = ["|", "|&"].freeze
49
+ # Verbs that change the dir ShellPaths::Walk follows.
50
+ DIR_VERBS = %w[cd pushd popd].freeze
51
+ # The joins a group (or the whole command) holding a cd or pushd may
52
+ # use in write mode: after any other a command can run in a dir the
53
+ # cd didn't reach (cd failed, or was skipped by ||).
54
+ DIR_JOINS = ["&&", "|", "|&"].freeze
55
+ # A dir verb's operand the shell resolves as Walk does: absolute, or
56
+ # ./ ../ (CDPATH is not searched for these).
57
+ DIR_OPERAND = %r{\A(?:/|\.\.?(?:/|\z))}
58
+
59
+ # The write side of .read_only_but_writes?: the Walk that follows the
60
+ # command's dir and the caller's test for an absolute path it writes.
61
+ Writes = Data.define(:walk, :writable) do
62
+ # +word+ as absolute paths against the current dir (ShellPaths::Walk:
63
+ # its lexical one, and its physical one when they differ, last); []
64
+ # when it can't be resolved, or has a $ or ~ (the shell may expand
65
+ # them from an env Walk doesn't see, as task_create's env: sets).
66
+ def paths(word) = word.include?("$") || word.start_with?("~") ? [] : walk.paths(word)
67
+
68
+ # Whether a cd or pushd with +args+ lands where Walk says: one
69
+ # operand (DIR_OPERAND) naming an existing dir every way it
70
+ # resolves. popd never does.
71
+ def dir_step?(verb, args)
72
+ return false unless verb != "popd" && args.size == 1 && args.first.match?(DIR_OPERAND)
73
+
74
+ dirs = paths(args.first)
75
+ !dirs.empty? && dirs.all? { |dir| File.directory?(dir) }
76
+ end
77
+
78
+ def ok?(path) = path ? writable.call(path) == true : false
79
+
80
+ # Whether +word+ names a file the command may write, every way it
81
+ # resolves.
82
+ def file?(word)
83
+ paths = paths(word)
84
+ !paths.empty? && paths.all? { |path| ok?(path) }
85
+ end
86
+ end
32
87
 
33
88
  # Verbs that only read whatever their options: → nil. The others
34
89
  # check their arguments.
@@ -49,29 +104,104 @@ module Samagotchi
49
104
  false
50
105
  end
51
106
 
107
+ # Whether the command only reads but for writes whose resolved paths
108
+ # the block accepts: > and >> targets, cp's destination (and the
109
+ # files it makes in it) and tee's files. Paths resolve against +cwd+
110
+ # and the dir an earlier cd, pushd or popd moved to; a cd beside a pipe
111
+ # (a subshell's) isn't one Walk can follow, so it is not.
112
+ # @param command [String, nil]
113
+ # @param cwd [String, nil] where the command starts
114
+ # @yieldparam path [String] an absolute path, symlinks not resolved
115
+ # @return [Boolean]
116
+ def read_only_but_writes?(command, cwd:, home: Dir.home, env: ENV, &writable)
117
+ text = command.to_s
118
+ return false if text.strip.empty?
119
+
120
+ tokens = ShellLex.lex(text)
121
+ commands = split(tokens, marks: true) or return false
122
+ return false if commands.none?(Array) || !dir_joins_ok?(tokens)
123
+
124
+ writes = Writes.new(walk: ShellPaths::Walk.new(cwd, home, env), writable: writable)
125
+ commands.all? { |words| words.is_a?(Symbol) ? writes.walk.step(words) : simple_read_only?(words, writes) }
126
+ rescue ArgumentError, EncodingError
127
+ false
128
+ end
129
+
52
130
  # The simple commands as [text, specials] word lists; nil when an
53
- # operator isn't one read-only commands may be joined with.
54
- def split(tokens)
131
+ # operator isn't one read-only commands may be joined with. With
132
+ # +marks+, ( and ) stay as :open / :close, and a cd, pushd or popd
133
+ # beside a pipe is nil.
134
+ def split(tokens, marks: false)
55
135
  commands = [[]]
136
+ piped = false
56
137
  tokens.each do |kind, value, specials|
57
138
  if kind == :op
58
139
  return nil unless JOINS.include?(value)
59
140
 
141
+ pipe = PIPES.include?(value)
142
+ return nil if marks && (piped || pipe) && dir_verb?(commands.last)
143
+
144
+ piped = pipe
145
+ commands << (value == "(" ? :open : :close) if marks && ["(", ")"].include?(value)
60
146
  commands << []
61
147
  else
62
148
  commands.last << [value, specials]
63
149
  end
64
150
  end
65
- commands.reject(&:empty?)
151
+ return nil if marks && piped && dir_verb?(commands.last)
152
+
153
+ commands.reject { |words| words.is_a?(Array) && words.empty? }
154
+ end
155
+
156
+ def dir_verb?(words) = words.is_a?(Array) && DIR_VERBS.include?(verb_of(words))
157
+
158
+ # Whether each group holding a dir verb itself (the whole command,
159
+ # or a ( … ): nested ones are their own) joins with DIR_JOINS only.
160
+ def dir_joins_ok?(tokens)
161
+ scopes = [[false, false]] # [holds a dir verb, has another join]
162
+ words = []
163
+ tokens.each do |kind, value, specials|
164
+ next words << [value, specials] unless kind == :op
165
+
166
+ scopes.last[0] ||= dir_verb?(words)
167
+ words = []
168
+ case value
169
+ when "(" then scopes << [false, false]
170
+ when ")"
171
+ dir, other = scopes.pop
172
+ return false if scopes.empty? || (dir && other)
173
+ else scopes.last[1] ||= !DIR_JOINS.include?(value)
174
+ end
175
+ end
176
+ scopes.last[0] ||= dir_verb?(words)
177
+ scopes.none? { |dir, other| dir && other }
66
178
  end
67
179
 
68
- def simple_read_only?(words)
180
+ # The first word that is no redirection or its target.
181
+ def verb_of(words)
182
+ target = false
183
+ words.each do |text, specials|
184
+ if target then target = false
185
+ elsif specials.include?(">") then target = text.match?(BARE_REDIRECT)
186
+ else return text
187
+ end
188
+ end
189
+ nil
190
+ end
191
+
192
+ # With +writes+ (.read_only_but_writes?) its redirections may write
193
+ # what it accepts, cp and tee count, and the walk takes the command.
194
+ def simple_read_only?(words, writes = nil)
69
195
  return false if words.any? { |text, specials| text.include?(ShellLex::SUBST) || !expansion_ok?(text, specials) }
70
196
 
71
- args = strip_redirects(words) or return false
197
+ args = strip_redirects(words, writes) or return false
198
+ return false if writes && DIR_VERBS.include?(args.first) && !writes.dir_step?(args.first, args.drop(1))
199
+
200
+ writes&.walk&.step(args)
72
201
  verb = args.shift
73
202
  return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
74
203
  return true if PLAIN.include?(verb)
204
+ return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
75
205
 
76
206
  check = CHECKS[verb] or return false
77
207
  check.call(args)
@@ -85,19 +215,20 @@ module Samagotchi
85
215
  end
86
216
 
87
217
  # The words with their redirections taken out; nil when one isn't an
88
- # fd dup or to /dev/null.
89
- def strip_redirects(words)
218
+ # fd dup, to /dev/null, or (with +writes+) to a file it accepts.
219
+ def strip_redirects(words, writes = nil)
90
220
  out = []
91
221
  pending = false
92
222
  words.each do |text, specials|
93
223
  if pending
94
- return nil unless text == "/dev/null"
224
+ return nil unless text == "/dev/null" || (writes && specials.delete("$").empty? && writes.file?(text))
95
225
 
96
226
  pending = false
97
227
  elsif specials.include?("<")
98
228
  return nil
99
229
  elsif specials.include?(">")
100
230
  next if text.match?(FD_DUP) || text.match?(NULL_REDIRECT)
231
+ next if writes && (m = text.match(REDIRECT_TO)) && !m[1].match?(/[<>]/) && writes.file?(m[1])
101
232
  return nil unless text.match?(BARE_REDIRECT)
102
233
 
103
234
  pending = true
@@ -272,6 +403,58 @@ module Samagotchi
272
403
  names.zero? || options.any? { |a| GIT_BRANCH_LISTING.any? { |listing| a == listing || a.start_with?("#{listing}=") } }
273
404
  end
274
405
 
406
+ CP_SHORT = "afinpvrRPLHxT"
407
+ CP_LONG = %w[--archive --force --interactive --no-clobber --recursive --verbose --dereference
408
+ --no-dereference --one-file-system --no-target-directory].freeze
409
+ CP_PRESERVE = /\A--(?:no-)?preserve(?:=[\w,]+)?\z/
410
+
411
+ # cp whose destination (the last argument, or -t / --target-directory)
412
+ # and the files it makes in it, when it is a dir, the caller accepts.
413
+ # Only options that copy: no -l/-s (links into the source), --parents
414
+ # or --backup; anything unknown is not.
415
+ def cp(args, writes)
416
+ target = nil
417
+ no_target = false
418
+ positional = []
419
+ rest = args.dup
420
+ until rest.empty?
421
+ arg = rest.shift
422
+ if !arg.start_with?("-") || arg == "-" then positional << arg
423
+ elsif arg == "--" then positional.concat(rest.shift(rest.size))
424
+ elsif (m = arg.match(/\A-([#{CP_SHORT}]*)t(.*)\z/m))
425
+ no_target ||= m[1].include?("T")
426
+ target = m[2].empty? ? rest.shift : m[2]
427
+ return false unless target
428
+ elsif arg == "--target-directory" then target = rest.shift or return false
429
+ elsif (m = arg.match(/\A--target-directory=(.+)\z/m)) then target = m[1]
430
+ elsif arg.match?(/\A-[#{CP_SHORT}]+\z/) then no_target ||= arg.include?("T")
431
+ elsif CP_LONG.include?(arg) || arg.match?(CP_PRESERVE) then no_target ||= arg == "--no-target-directory"
432
+ else return false
433
+ end
434
+ end
435
+ into = !target.nil?
436
+ target ||= positional.pop
437
+ return false if positional.empty? || (into && no_target)
438
+
439
+ dests = writes.paths(target)
440
+ return false if dests.empty?
441
+
442
+ into ||= !no_target && File.directory?(dests.last)
443
+ made = into ? dests.product(positional).map { |dest, src| File.join(dest, File.basename(src)) } : []
444
+ [*dests, *made].all? { |path| writes.ok?(path) }
445
+ end
446
+
447
+ TEE_OPTIONS = %w[-a --append -i --ignore-interrupts -p].freeze
448
+
449
+ # tee whose every file the caller accepts.
450
+ def tee(args, writes)
451
+ files = args.reject { |arg| TEE_OPTIONS.include?(arg) }
452
+ files.all? { |file| !file.start_with?("-") && writes.file?(file) }
453
+ end
454
+
455
+ # The verbs that write, for .read_only_but_writes? only: → (args, writes).
456
+ WRITE_CHECKS = { "cp" => method(:cp), "tee" => method(:tee) }.freeze
457
+
275
458
  CHECKS = {
276
459
  "rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
277
460
  "sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "protected_paths"
3
+ require_relative "chi_dirs"
4
+ require_relative "path_resolve"
4
5
  require_relative "shell_paths"
5
6
  require_relative "../paths"
6
7
 
@@ -10,9 +11,11 @@ module Samagotchi
10
11
  # under the session's project root that isn't on the read denylist
11
12
  # (chi's state and config dirs, home secret dirs, project secret files),
12
13
  # checked on the path as given and as it resolves (a symlink in the
13
- # project to ~/.ssh is ~/.ssh). ReviewBoundary asks it for every read of
14
- # a participant's turn; a review session's subjects (chi review, the
15
- # participant page's Plan) go through the same check.
14
+ # project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
15
+ # of its target, as the kernel opens it: PathResolve.reals).
16
+ # ReviewBoundary asks it for every read of a participant's turn; a
17
+ # review session's subjects (chi review, the participant page's Plan)
18
+ # go through the same check.
16
19
  class ReviewPaths
17
20
  # Home folders that hold keys and tokens, never read in a review turn
18
21
  # (nor read or written in a pair turn).
@@ -43,8 +46,20 @@ module Samagotchi
43
46
  SECRET = :secret
44
47
  HOME_ROOT = :home_root
45
48
 
46
- # chi's state dir and config dir, as Paths has them now.
47
- def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
49
+ # chi's state dir, config dir and a hooks dir configured outside it
50
+ # (ChiDirs.review).
51
+ def self.chi_dirs = ChiDirs.review
52
+
53
+ # Whether +root+ is the home folder or a folder above it (symlinks
54
+ # resolved on both): no project to keep a participant's turn in. A
55
+ # review turn reads nothing there (#refusal: HOME_ROOT); a pair turn
56
+ # asks the owner before every write (PairBoundary).
57
+ # @param root [String] @param home [String, nil]
58
+ def self.home_root?(root, home)
59
+ return false if home.to_s.empty? || root.to_s.empty?
60
+
61
+ PathResolve.under?(PathResolve.real(home.to_s), PathResolve.real(root.to_s))
62
+ end
48
63
 
49
64
  # @param root [String, nil] the session's project root (the work tree
50
65
  # top of its folder); nil or blank refuses every path
@@ -66,14 +81,15 @@ module Samagotchi
66
81
  def refusal(paths)
67
82
  return OUTSIDE if paths.empty? || @root.empty?
68
83
 
69
- real_root = ProtectedPaths.real(@root)
70
- return HOME_ROOT if !@home.empty? && ShellPaths.within?(ProtectedPaths.real(@home), real_root)
84
+ return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
85
+
86
+ real_root = PathResolve.real(@root)
71
87
 
72
88
  paths.each do |path|
73
89
  given = File.expand_path(path)
74
- real = ProtectedPaths.real(given)
75
- return OUTSIDE unless ShellPaths.within?(real, real_root)
76
- return SECRET if secret?(given) || secret?(real)
90
+ reals = PathResolve.reals(path)
91
+ return OUTSIDE unless reals.all? { |real| PathResolve.under?(real, real_root) }
92
+ return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
77
93
  end
78
94
  nil
79
95
  end
@@ -86,7 +102,7 @@ module Samagotchi
86
102
  def secret_refusal(paths)
87
103
  paths.each do |path|
88
104
  given = File.expand_path(path)
89
- return SECRET if secret?(given) || secret?(ProtectedPaths.real(given))
105
+ return SECRET if secret?(given) || PathResolve.reals(path).any? { |real| secret?(real) }
90
106
  end
91
107
  nil
92
108
  end
@@ -99,13 +115,13 @@ module Samagotchi
99
115
  @denied_roots ||= begin
100
116
  roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
101
117
  roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
102
- (roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
118
+ (roots + roots.map { |root| PathResolve.real(root) }).uniq
103
119
  end
104
120
  end
105
121
 
106
122
  # Whether +path+ (absolute) is on the read denylist.
107
123
  def secret?(path)
108
- return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
124
+ return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
109
125
 
110
126
  parts = path.split(File::SEPARATOR)
111
127
  return true if parts.any? { |part| SECRET_FILE.match?(part) }