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
@@ -48,7 +48,8 @@ module Samagotchi
48
48
  # word resolved as #touches? resolves it); a word that can't be
49
49
  # resolved, a command with a $(…) or backticks, or one that doesn't
50
50
  # lex, by the text match +text+ instead.
51
- # @yieldparam path [String] an absolute path (not resolved for symlinks)
51
+ # @yieldparam path [String] an absolute path (not resolved for
52
+ # symlinks but for a `..` climbing out of one: Walk)
52
53
  def any_path?(command, text:, cwd:, home: Dir.home, env: ENV)
53
54
  walk = Walk.new(cwd, home, env)
54
55
  tokens = ShellLex.lex(command.to_s)
@@ -63,21 +64,35 @@ module Samagotchi
63
64
  end
64
65
 
65
66
  def names?(path, roots, tmp_roots)
66
- real = ProtectedPaths.real(path)
67
- return true if roots.any? { |root| within?(real, root) }
67
+ ProtectedPaths.reals(path).any? do |real|
68
+ next true if roots.any? { |root| within?(real, root) }
68
69
 
69
- real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
70
+ real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
71
+ end
70
72
  end
71
73
 
72
74
  def within?(path, root) = path == root || path.start_with?(File.join(root, ""))
73
75
 
74
76
  # Follows the current dir and gives, per simple command, each word
75
- # with its path (nil when it can't be resolved).
77
+ # with its path (nil when it can't be resolved). A word is opened the
78
+ # kernel's way, so a `..` after a symlinked dir climbs out of its
79
+ # target: such a word comes twice, with its lexical path and its
80
+ # physical one (ProtectedPaths.expand), and a check reads both. A cd
81
+ # or pushd to such a word may land at either (the shell's logical cd
82
+ # chdirs to the physical path when the logical one doesn't exist, and
83
+ # always with -P, set -P or zsh's chase_links/chase_dots), so the walk
84
+ # keeps every dir the command may be in by then and resolves later
85
+ # words against each (a word not resolvable from one of them isn't
86
+ # resolved). A symlink without a `..` lands at the same file either
87
+ # way.
76
88
  class Walk
77
89
  include RedactedInspect
78
90
 
91
+ # More dirs than this after a cd: the dir is unknown.
92
+ MAX_DIRS = 8
93
+
79
94
  def initialize(cwd, home, env)
80
- @dir = cwd
95
+ @dirs = [cwd]
81
96
  @home = home
82
97
  @env = env
83
98
  @subshells = []
@@ -88,45 +103,78 @@ module Samagotchi
88
103
  # @return [Array<[String, String|nil]>]
89
104
  def step(words)
90
105
  case words
91
- when :open then @subshells << @dir
106
+ when :open then @subshells << @dirs
92
107
  []
93
- when :close then @dir = @subshells.pop || @dir
108
+ when :close then @dirs = @subshells.pop || @dirs
94
109
  []
95
110
  else command(words)
96
111
  end
97
112
  end
98
113
 
99
- # An absolute path for +word+ against the current dir, or nil.
100
- def path(word) = resolve(word, @dir)
114
+ # Every dir the command may be in by now (nil for one it can't tell).
115
+ def dirs = @dirs.dup
116
+
117
+ # The part of +word+ that may be a path: an option's or an
118
+ # assignment's value, a redirection's target; nil for an option
119
+ # alone or an operator.
120
+ def candidate(word)
121
+ word = word.sub(REDIRECT_PREFIX, "")
122
+ word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
123
+ return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
124
+
125
+ word
126
+ end
127
+
128
+ # The absolute paths for +word+ against every dir the command may be
129
+ # in (.paths_for), [] when it can't be resolved from one of them.
130
+ def paths(word)
131
+ found = @dirs.map { |dir| paths_for(word, dir) }
132
+ found.any?(&:empty?) ? [] : found.flatten.uniq
133
+ end
101
134
 
102
135
  private
103
136
 
104
137
  def command(words)
105
138
  verb = words.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) }
106
- resolved = words.filter_map { |word| candidate(word) }.map { |word| [word, resolve(word, @dir)] }
107
- cd_args = words.drop_while { |w| w != verb }.drop(1).grep_v(/\A-/)
139
+ cd_args = words.drop_while { |w| w != verb }.drop(1).grep_v(/\A-./)
140
+ resolved = words.filter_map { |word| candidate(word) }.flat_map do |word|
141
+ paths = paths(word)
142
+ paths.empty? ? [[word, nil]] : paths.map { |path| [word, path] }
143
+ end
108
144
  case verb
109
- when "cd" then @dir = cd_args.empty? ? @home : resolve(cd_args.first, @dir)
145
+ when "cd" then @dirs = cd_args.empty? ? [@home] : dirs_after(cd_args.first)
110
146
  when "pushd"
111
- @pushed << @dir
112
- @dir = cd_args.first && resolve(cd_args.first, @dir)
113
- when "popd" then @dir = @pushed.pop unless @pushed.empty?
147
+ @pushed << @dirs
148
+ @dirs = cd_args.first ? dirs_after(cd_args.first) : [nil]
149
+ when "popd" then @dirs = @pushed.pop unless @pushed.empty?
114
150
  end
115
151
  resolved
116
152
  end
117
153
 
118
- # The part of +word+ that may be a path: an option's or an
119
- # assignment's value, a redirection's target; nil for an option
120
- # alone or an operator.
121
- def candidate(word)
122
- word = word.sub(REDIRECT_PREFIX, "")
123
- word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
124
- return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
154
+ # Every dir a cd to +word+ may land at, from each current one (nil
155
+ # for one it can't tell: `cd -` among them); [nil] past MAX_DIRS.
156
+ def dirs_after(word)
157
+ return [nil] if word == "-"
125
158
 
126
- word
159
+ dirs = @dirs.flat_map do |dir|
160
+ found = paths_for(word, dir)
161
+ found.empty? ? [nil] : found
162
+ end.uniq
163
+ dirs.size > MAX_DIRS ? [nil] : dirs
164
+ end
165
+
166
+ # +word+ as a file argument against +dir+: its lexical path, and its
167
+ # physical one when a `..` in it climbs out of a symlinked dir's
168
+ # target (ProtectedPaths.expand); [] when it can't be resolved.
169
+ def paths_for(word, dir)
170
+ lexical = resolve(word, dir) or return []
171
+ return [lexical] unless word.include?("..")
172
+
173
+ [lexical, ProtectedPaths.expand(expand(word), dir)].uniq
127
174
  end
128
175
 
129
- # An absolute path for +word+ against +dir+, or nil.
176
+ # An absolute path for +word+ against +dir+ with `..` taken
177
+ # lexically, or nil.
130
178
  def resolve(word, dir)
131
179
  return nil if word.include?(ShellLex::SUBST) || word == ShellLex::HEREDOC || word.match?(SCRIPT)
132
180
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../tools/tool_path"
4
5
  require_relative "../tools/memory"
5
6
  require_relative "../tool_declarations"
@@ -14,6 +15,7 @@ require_relative "shell_paths"
14
15
  require_relative "rm_targets"
15
16
  require_relative "protected_paths"
16
17
  require_relative "../memory_paths"
18
+ require_relative "../redacted_inspect"
17
19
 
18
20
  module Samagotchi
19
21
  module Guardrails
@@ -21,8 +23,12 @@ module Samagotchi
21
23
  # shell command, absolute paths (against Dir.pwd, as the file tools have
22
24
  # no cwd:), the directory a command runs in, its repo root, and whether
23
25
  # a path leaves the session's repo (Outside: the context cwd's repo
24
- # root, the cwd when there is no repo; not the call's own cwd:).
26
+ # root, the cwd when there is no repo; not the call's own cwd:). A
27
+ # path whose `..` climbs out of a symlinked dir's target is in #paths
28
+ # twice: lexically and as the kernel opens it (.absolutes).
25
29
  class Targets
30
+ include RedactedInspect # @env: the call's env: may hold a secret
31
+
26
32
  SHELL_TOOLS = %w[execute task_create].freeze
27
33
  PATH_TOOLS = %w[write edit read memory_write].freeze
28
34
 
@@ -31,6 +37,24 @@ module Samagotchi
31
37
 
32
38
  attr_reader :tool, :command, :paths, :cwd, :repo_root
33
39
 
40
+ # @return [Hash{String=>String}] the variables a task_create call's
41
+ # env: sets for its command ({} for none, and for every other tool)
42
+ attr_reader :env
43
+
44
+ # Variables that change what a command's words run or where its cd
45
+ # goes, under every shell chi may use or in the programs it runs: a
46
+ # startup file (BASH_ENV, ENV), an exported function (BASH_FUNC_…), a
47
+ # cd search path, word splitting, shell options, a library loaded into
48
+ # every program, git config or a diff program that runs. A call whose
49
+ # env: sets one is never read-only (.redefines_shell?). PATH is not
50
+ # one (the tool's own description suggests it); nor are
51
+ # PROMPT_COMMAND (interactive shells only) and ZDOTDIR (zsh in sh
52
+ # emulation reads no rc files).
53
+ SHELL_REDEFINING_ENV = %w[BASH_ENV ENV CDPATH IFS SHELLOPTS BASHOPTS LD_PRELOAD LD_AUDIT
54
+ DYLD_INSERT_LIBRARIES GIT_EXTERNAL_DIFF GIT_CONFIG_PARAMETERS
55
+ GIT_CONFIG_COUNT].freeze
56
+ SHELL_REDEFINING_PREFIX = "BASH_FUNC_"
57
+
34
58
  # @return [String, nil] the call's repository (Context#repo: its
35
59
  # common git dir, shared by its worktrees): what a "this repo"
36
60
  # approval is kept for
@@ -96,15 +120,17 @@ module Samagotchi
96
120
  args = nil
97
121
  acts_as = nil
98
122
  label = nil
123
+ env = {}
99
124
  case tool
100
125
  when *SHELL_TOOLS
101
126
  command = call[:content].to_s
127
+ env = env_of(call[:env])
102
128
  given = call[:cwd].to_s.strip
103
129
  cwd = File.expand_path(given, base) unless given.empty?
104
130
  when "write", "edit"
105
- paths << absolute(call[:path], base)
131
+ paths.concat(absolutes(call[:path], base))
106
132
  when "read"
107
- paths << absolute(call[:content], base)
133
+ paths.concat(absolutes(call[:content], base))
108
134
  when "memory_write"
109
135
  paths << memory_path(call, model_key)
110
136
  else
@@ -112,7 +138,7 @@ module Samagotchi
112
138
  command = given[:command]
113
139
  given_cwd = given[:cwd].to_s.strip
114
140
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
115
- paths.concat(given[:paths].map { |path| absolute(path, cwd) })
141
+ paths.concat(given[:paths].flat_map { |path| absolutes(path, cwd) })
116
142
  entry = registry && registry[tool]
117
143
  args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
118
144
  acts_as = given[:acts_as]
@@ -121,7 +147,7 @@ module Samagotchi
121
147
  memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
122
148
  memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
123
149
  memory = tool == "memory_write" ? memory_facts(call) : nil
124
- new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
150
+ new(tool: tool, command: command, paths: paths.compact, cwd: cwd, env: env, repo_root: context.repo_root(cwd),
125
151
  repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
126
152
  memory_overlay: memory_overlay, memory: memory,
127
153
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
@@ -214,9 +240,33 @@ module Samagotchi
214
240
  ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
215
241
  end
216
242
 
217
- def self.absolute(path, base)
243
+ # A shell call's env: as the tool reads it (an object, or JSON text
244
+ # for one): {} for none, and for one the tool would refuse (it then
245
+ # runs nothing).
246
+ def self.env_of(given)
247
+ given = JSON.parse(given) if given.is_a?(String) && !given.strip.empty?
248
+ return {} unless given.is_a?(Hash)
249
+
250
+ given.to_h { |key, value| [key.to_s, value.to_s] }.freeze
251
+ rescue JSON::ParserError
252
+ {}
253
+ end
254
+
255
+ # Whether +env+ sets a SHELL_REDEFINING_ENV variable.
256
+ def self.redefines_shell?(env)
257
+ env.each_key.any? { |key| SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) }
258
+ end
259
+
260
+ # +path+ (a file tool's, as Tools::ToolPath has it) absolute against
261
+ # +base+: its lexical path (File.expand_path), and the one the kernel
262
+ # opens when a `..` in it climbs out of a symlinked dir's target
263
+ # (ProtectedPaths.expand) after it; [] for a blank path. Every check
264
+ # reads all of #paths, so it judges both.
265
+ def self.absolutes(path, base)
218
266
  path = Tools::ToolPath.normalize(path)
219
- path.empty? ? nil : File.expand_path(path, base)
267
+ return [] if path.empty?
268
+
269
+ [File.expand_path(path, base), ProtectedPaths.expand(path, base)].uniq
220
270
  end
221
271
 
222
272
  # The file memory_write would write; nil when the call is invalid.
@@ -245,9 +295,11 @@ module Samagotchi
245
295
  end
246
296
 
247
297
  # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
248
- def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
249
- model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false,
250
- memory_overlay: false, memory: nil)
298
+ # @param env [Hash{String=>String}] the call's env: (.env_of)
299
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, env: {}, repo: nil, args: nil, acts_as: nil,
300
+ label: nil, model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil,
301
+ memory_remove: false, memory_overlay: false, memory: nil)
302
+ @env = env
251
303
  @acts_as = acts_as
252
304
  @label = label
253
305
  @memory_remove = memory_remove
@@ -268,6 +320,28 @@ module Samagotchi
268
320
 
269
321
  def shell? = SHELL_TOOLS.include?(@tool)
270
322
 
323
+ # The homes and environments a shell call's words may resolve
324
+ # against, as [home, env] pairs: chi's own, and when the call's env:
325
+ # sets anything, that env over chi's (its HOME for ~ and a bare cd).
326
+ # A check that asks when any pair crosses only ever asks more for an
327
+ # env: than without one. Not kept (chi's env holds secrets).
328
+ def shell_envs
329
+ own = [Dir.home, ENV]
330
+ return [own] if @env.empty?
331
+
332
+ home = @env["HOME"].to_s
333
+ [own, [home.empty? ? Dir.home : home, ENV.to_h.merge(@env)]]
334
+ end
335
+
336
+ # Whether the call's env: sets a variable that changes what the
337
+ # command's words run (.redefines_shell?): such a command is never
338
+ # read-only, nor tmp-write-only.
339
+ def redefines_shell?
340
+ return @redefines_shell if defined?(@redefines_shell)
341
+
342
+ @redefines_shell = self.class.redefines_shell?(@env)
343
+ end
344
+
271
345
  # What a rule's command: reads: the shell command with the body of each
272
346
  # heredoc its command only reads as data left out (ShellLex.code_text);
273
347
  # a plugin tool's own command as given (its text may not be shell).
@@ -310,14 +384,37 @@ module Samagotchi
310
384
  # Where a shell call runs mutating git (ShellGitDirs: dirs, and
311
385
  # :unknown for one the text doesn't tell); [] for other tools.
312
386
  def git_dirs
313
- @git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
387
+ @git_dirs ||= if shell?
388
+ # chi's own GIT_DIR is not the command's concern
389
+ shell_envs.each_with_index.flat_map do |(home, _), i|
390
+ ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env)
391
+ end.uniq
392
+ else
393
+ []
394
+ end
314
395
  end
315
396
 
316
397
  # Whether the command only reads (ReadOnlyShell); false without one.
317
398
  def read_only?
318
399
  return @read_only if defined?(@read_only)
319
400
 
320
- @read_only = !@command.nil? && ReadOnlyShell.read_only?(@command)
401
+ @read_only = !@command.nil? && !redefines_shell? && ReadOnlyShell.read_only?(@command)
402
+ end
403
+
404
+ # Whether the command only reads but for writes into a tmp dir that
405
+ # +root+ isn't in (ReadOnlyShell.read_only_but_writes?,
406
+ # Outside.tmp_write?): its > / >> targets, cp's destination and tee's
407
+ # files, from the call's cwd and any cd before them. The child
408
+ # boundary's; #read_only? stays what the rules read. Never for a call
409
+ # whose env: redefines the shell (#redefines_shell?); the writes must
410
+ # hold under each of #shell_envs.
411
+ # @param root [String] the folder the writes must stay out of
412
+ def read_only_but_tmp_writes?(root:)
413
+ !@command.nil? && !redefines_shell? && shell_envs.all? do |home, env|
414
+ ReadOnlyShell.read_only_but_writes?(@command, cwd: @cwd, home: home, env: env) do |path|
415
+ Outside.tmp_write?(path, root: root)
416
+ end
417
+ end
321
418
  end
322
419
 
323
420
  # A memory file that reaches the system prompt: the identity memory and
@@ -338,36 +435,43 @@ module Samagotchi
338
435
  # disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
339
436
  # loads.
340
437
  def self.prompt_memory_path?(path)
341
- real = ProtectedPaths.real(path)
342
- return false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
438
+ ProtectedPaths.reals(path).any? do |real|
439
+ next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
343
440
 
344
- dir = File.dirname(real)
345
- dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
346
- File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
441
+ dir = File.dirname(real)
442
+ dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
443
+ File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
444
+ end
347
445
  rescue StandardError
348
446
  false
349
447
  end
350
448
 
351
449
  # Whether the command names a path in chi's own dirs (ShellPaths:
352
- # resolved paths; the text CHI_SHELL_TEXT for a word that can't be
353
- # resolved); false without a command.
450
+ # resolved paths, under any of #shell_envs; the text CHI_SHELL_TEXT
451
+ # for a word that can't be resolved); false without a command.
354
452
  def touches_chi?
355
453
  return @touches_chi if defined?(@touches_chi)
356
454
 
357
455
  @touches_chi = !@command.nil? && begin
358
456
  require_relative "parent_approvals"
359
- ShellPaths.touches?(@command, dirs: @chi_dirs ? @chi_dirs.call : ParentApprovals.chi_dirs,
360
- text: ParentApprovals::CHI_SHELL_TEXT, cwd: @cwd, tmp_roots: Outside.tmp_roots)
457
+ dirs = @chi_dirs ? @chi_dirs.call : ParentApprovals.chi_dirs
458
+ shell_envs.any? do |home, env|
459
+ ShellPaths.touches?(@command, dirs: dirs, text: ParentApprovals::CHI_SHELL_TEXT, cwd: @cwd, home: home,
460
+ env: env, tmp_roots: Outside.tmp_roots)
461
+ end
361
462
  end
362
463
  end
363
464
 
364
465
  # Whether an rm -rf in the command reaches outside the tmp dirs
365
- # (RmTargets; a tmp dir the session's root is in doesn't count as one).
466
+ # (RmTargets, under any of #shell_envs; a tmp dir the session's root
467
+ # is in doesn't count as one).
366
468
  def rm_outside_tmp?
367
469
  return @rm_outside_tmp if defined?(@rm_outside_tmp)
368
470
 
369
- @rm_outside_tmp = !@command.nil? &&
370
- RmTargets.outside_tmp?(@command, cwd: @cwd, tmp_roots: Outside.tmp_roots, session_root: @session_root)
471
+ @rm_outside_tmp = !@command.nil? && shell_envs.any? do |home, env|
472
+ RmTargets.outside_tmp?(@command, cwd: @cwd, tmp_roots: Outside.tmp_roots, session_root: @session_root,
473
+ home: home, env: env)
474
+ end
371
475
  end
372
476
 
373
477
  # Whether a shell call runs mutating git outside the session's repo
@@ -27,6 +27,7 @@ require_relative "process_group"
27
27
  require_relative "bridge_client"
28
28
  require_relative "worker_sidecar"
29
29
  require_relative "worker_link"
30
+ require_relative "worker_idle_exit"
30
31
  require_relative "worker_signals"
31
32
  require_relative "chi_invocation"
32
33
  require_relative "log"
@@ -676,7 +677,7 @@ module Samagotchi
676
677
  command_running: "a /btw (or another command) is still running",
677
678
  relays_open: "a delegate's approval relayed here is open or just answered",
678
679
  background_tasks: "background tasks it started are still running"
679
- }.freeze
680
+ }.merge(WorkerIdleExit::LEAVING_WORDS).freeze
680
681
 
681
682
  # What the CLI and the web say.
682
683
  def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
@@ -1408,12 +1409,12 @@ module Samagotchi
1408
1409
  end
1409
1410
  manager.resume_session(session_id, state_dir: state_dir)
1410
1411
  if (client = bridge.call)
1411
- return verify_unavailable if verify && !worker_has?(session_dir, VerifyAnchor::FEATURE)
1412
+ return verify_unavailable if verify && !worker_has?(client, VerifyAnchor::FEATURE)
1412
1413
  # A worker from before queueing (no "delivery" feature) ignores the
1413
1414
  # field or refuses it; either way a file would steer the running turn.
1414
- return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
1415
+ return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
1415
1416
  # A participant's turn only to a worker with the review boundary.
1416
- return review_unavailable if Steer.participant_author?(author) && !worker_has?(session_dir, Delivery::REVIEW_FEATURE)
1417
+ return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
1417
1418
 
1418
1419
  begin
1419
1420
  options = { prompt: prompt, client_id: client_id }
@@ -1490,17 +1491,10 @@ module Samagotchi
1490
1491
  ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
1491
1492
  end
1492
1493
 
1493
- # Whether the session's live worker runs delivery: queue (its sidecar
1494
- # names the feature). No sidecar to read (the client came from a stand-in,
1495
- # or it went between the wait and here): true, the post finds out.
1496
- private_class_method def self.worker_queues?(session_dir) = worker_has?(session_dir, Delivery::QUEUE_FEATURE)
1497
-
1498
- # Whether the session's live worker names +feature+ in its sidecar; no
1499
- # sidecar to read: true, as for #worker_queues?.
1500
- private_class_method def self.worker_has?(session_dir, feature)
1501
- sidecar = WorkerSidecar.read(session_dir)
1502
- sidecar.nil? || sidecar.features.include?(feature)
1503
- end
1494
+ # Whether the worker +client+ talks to names +feature+: the features of
1495
+ # the sidecar the client was made from (the Live link WorkerLink.await
1496
+ # found), not a second read of bridge.json.
1497
+ private_class_method def self.worker_has?(client, feature) = client.features.include?(feature)
1504
1498
 
1505
1499
  # The unclaimed input files +author+ wrote.
1506
1500
  PARTICIPANT_QUEUE_LOCK = "participants.lock"
@@ -2,8 +2,11 @@
2
2
 
3
3
  require_relative "../config"
4
4
  require_relative "../memory_paths"
5
+ require_relative "../paths"
5
6
  require_relative "../guardrails/protected_paths"
6
7
  require_relative "../guardrails/parent_approvals"
8
+ require_relative "../guardrails/shell_lex"
9
+ require_relative "../guardrails/shell_paths"
7
10
 
8
11
  module Samagotchi
9
12
  module SystemOne
@@ -14,6 +17,19 @@ module Samagotchi
14
17
  # "System 1"). The memories dir is deliberately not one of them:
15
18
  # reading and appending a memory file stays eligible.
16
19
  #
20
+ # A shell command's words are resolved as Guardrails::ShellPaths
21
+ # resolves them (cd chains, ~, $HOME and the XDG dirs, symlinks, `..`
22
+ # both ways), under chi's env and the call's env: when the caller hands
23
+ # its Targets; one landing in chi's dirs outside the memories refuses.
24
+ # The memories text is taken out of the command (the exemption) only
25
+ # when its text shows no climb (CLIMB: a `..`, a `$`, backticks, a `~`
26
+ # other than the home one, `cd -`) and every word either resolved or is
27
+ # data (Words#plain: a script or heredoc body, a phrase, a glob with no
28
+ # `/`) read while the dir was known. A word the walk can't follow
29
+ # otherwise (`${PWD%/*}`, `$(dirname $PWD)`, `cd -`) may climb out of the
30
+ # memories dir without saying so, so its memories text stays in and the
31
+ # text check refuses it.
32
+ #
17
33
  # Pure: it only reads the paths as this process's environment names them,
18
34
  # and answers on the approval's own facts (Guardrails::Approval.payload's
19
35
  # approval:), the same shape ParentApprovals.protected_facts? takes.
@@ -29,44 +45,120 @@ module Samagotchi
29
45
  # The characters that end a shell word: a memories path in a command
30
46
  # ends at the first one.
31
47
  WORD_END = %q{[\s'"`;&|<>(){}:,]}
32
- # A `..` segment anywhere in a command: with one, a memories path may
33
- # climb back out (`memories/../config.yml`, `cd memories && … ../x`),
34
- # so the command's memories text is not taken out.
35
- CLIMB = %r{(?:\A|[\s/'"=])\.\.(?:/|[\s'"]|\z)}
48
+
49
+ # A climb out of a dir as a command's text may spell it: a `..`
50
+ # segment (`cd ..;`, `../x`, inside a script body too), any `$`
51
+ # expansion or backticks, a `~` that isn't the home one (`~-`, `~+`,
52
+ # `~user`), or `cd -` / `pushd -`.
53
+ CLIMB = Regexp.union(
54
+ /(?<![\w.])\.\.(?![\w.])/, /[$`]/, %r{(?:\A|[\s=:'"(])~(?![/\s'";&|)]|\z)},
55
+ /(?:\A|[\s;&|(])(?:cd|pushd)\s+-(?=[\s;&|)]|\z)/
56
+ )
57
+
58
+ # What resolving a command's words found: whether one lands in chi's
59
+ # dirs (outside the memories dir, or in its installed bundles), and
60
+ # whether every word resolved or is data (#plain).
61
+ Words = Data.define(:chi, :plain)
36
62
 
37
63
  module_function
38
64
 
39
65
  # @param approval [Hash, nil] the ask's approval facts (rule, tool,
40
- # paths, command)
66
+ # paths, command, cwd)
67
+ # @param targets [Guardrails::Targets, nil] the call's targets, for its
68
+ # env: (Targets#shell_envs, #redefines_shell?)
41
69
  # @return [Boolean] whether System 1 must not answer this ask
42
- def refuse?(approval)
70
+ def refuse?(approval, targets: nil)
43
71
  return false unless approval.is_a?(Hash)
44
72
  return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
45
- # The test that makes an ask user_only (ParentApprovals#chi_files?:
46
- # chi's dirs, however a command spells them), minus the memories.
47
- return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval))
73
+ # An env: that redefines the shell (BASH_ENV, BASH_FUNC_…) runs what
74
+ # no word of the command says.
75
+ return true if targets&.redefines_shell?
48
76
 
49
77
  command = fetch(approval, :command).to_s
78
+ words = command.empty? ? nil : words(command, cwd: fetch(approval, :cwd), targets: targets)
79
+ return true if words&.chi
80
+ # The test that makes an ask user_only (ParentApprovals#chi_files?:
81
+ # chi's dirs as the command spells them), minus the memories.
82
+ return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval, exempt: exempt?(command, words)))
83
+
50
84
  command.match?(DEFAULT_STATE_TEXT)
51
85
  end
52
86
 
53
87
  # The approval's facts without what is in the memories dir (not its
54
88
  # installed bundles): the paths under it, and its spellings in the
55
- # command unless the command climbs with `..`.
56
- def beyond_memories(approval)
89
+ # command when +exempt+ (.exempt?).
90
+ def beyond_memories(approval, exempt:)
57
91
  memories = Guardrails::ProtectedPaths.real(MemoryPaths.system_dir)
58
92
  bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
59
93
  paths = Array(fetch(approval, :paths)).map(&:to_s).reject do |path|
60
- real = Guardrails::ProtectedPaths.real(path)
61
- Guardrails::ProtectedPaths.within?(real, memories) && !Guardrails::ProtectedPaths.within?(real, bundles)
94
+ Guardrails::ProtectedPaths.reals(path).all? { |real| memory?(real, memories, bundles) }
62
95
  end
63
96
  command = fetch(approval, :command).to_s
64
- unless command.match?(CLIMB)
65
- command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" }
66
- end
97
+ command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
67
98
  { paths: paths, command: command }
68
99
  end
69
100
 
101
+ # Whether the command's memories text may be taken out: no command,
102
+ # or one whose text shows no climb (CLIMB) and whose words are plain.
103
+ def exempt?(command, words)
104
+ words.nil? || (words.plain && !command.match?(CLIMB))
105
+ end
106
+
107
+ # The command's words resolved (ShellPaths::Walk) from +cwd+ (else
108
+ # this process's dir) under each of the call's shell envs: Words.
109
+ def words(command, cwd:, targets:)
110
+ memories = Guardrails::ProtectedPaths.real(MemoryPaths.system_dir)
111
+ bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
112
+ dirs = chi_dirs
113
+ tokens = Guardrails::ShellLex.lex(command)
114
+ plain = !Guardrails::ShellLex.unterminated?(command) &&
115
+ tokens.none? { |_, value| value.include?(Guardrails::ShellLex::SUBST) }
116
+ chi = false
117
+ commands = Guardrails::ShellLex.simple_commands(tokens)
118
+ envs = targets ? targets.shell_envs : [[Dir.home, ENV]]
119
+ envs.each do |home, env|
120
+ walk = Guardrails::ShellPaths::Walk.new(cwd.to_s.empty? ? Dir.pwd : cwd.to_s, home, env)
121
+ commands.each do |simple|
122
+ # A cd the walk couldn't follow: no later word is data.
123
+ known = !simple.is_a?(Array) || !walk.paths(".").empty?
124
+ walk.step(simple).each do |word, path|
125
+ next plain &&= known && data?(word) unless path
126
+
127
+ chi ||= Guardrails::ProtectedPaths.reals(path).any? do |real|
128
+ dirs.any? { |dir| Guardrails::ShellPaths.within?(real, dir) } && !memory?(real, memories, bundles)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ Words.new(chi: chi, plain: plain)
134
+ rescue ArgumentError, EncodingError
135
+ Words.new(chi: false, plain: false)
136
+ end
137
+
138
+ # Whether a word the walk couldn't resolve is data rather than a path
139
+ # spelt some other way: a heredoc body, a script or a phrase (spaces,
140
+ # quotes, operators: `python3 -c '…'`, `'- a note'`), or a glob with
141
+ # no `/`. A `$` or `~` left in it is not (CLIMB refuses those anyway).
142
+ def data?(word)
143
+ return true if word == Guardrails::ShellLex::HEREDOC
144
+ return false if word.include?("$") || word.include?("~")
145
+ return true if word.match?(Guardrails::ShellPaths::SCRIPT)
146
+
147
+ word.match?(Guardrails::ShellPaths::GLOB) && !word.include?("/")
148
+ end
149
+
150
+ # chi's own dirs (ParentApprovals.chi_dirs) and its whole state dir
151
+ # (DEFAULT_STATE_TEXT's), resolved.
152
+ def chi_dirs
153
+ [*Guardrails::ParentApprovals.chi_dirs, Paths.state_dir]
154
+ .map { |dir| Guardrails::ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
155
+ end
156
+
157
+ # Whether +real+ is in the memories dir and not in its installed bundles.
158
+ def memory?(real, memories, bundles)
159
+ Guardrails::ProtectedPaths.within?(real, memories) && !Guardrails::ProtectedPaths.within?(real, bundles)
160
+ end
161
+
70
162
  # The memories dir as a command spells it: the default
71
163
  # `.config/samagotchi/memories`, or this process's own path, with the
72
164
  # rest of that shell word.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../tools/task_record"
4
+ require_relative "../worker_idle_exit"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -23,7 +24,7 @@ module Samagotchi
23
24
  "reminders" => "reminders are set", "starting" => "the worker is still starting",
24
25
  "background_tasks" => "background tasks it started are still running",
25
26
  "delegates" => "delegates of this session are still running"
26
- }.freeze
27
+ }.merge(WorkerIdleExit::LEAVING_WORDS.transform_keys(&:to_s)).freeze
27
28
  # How to end a background task that holds the worker up (an older
28
29
  # worker's 409 has no hint of its own).
29
30
  TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"