samagotchi 0.128.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
@@ -1,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"
@@ -12,8 +13,10 @@ require_relative "shell_lex"
12
13
  require_relative "read_only_shell"
13
14
  require_relative "shell_paths"
14
15
  require_relative "rm_targets"
15
- require_relative "protected_paths"
16
+ require_relative "chi_dirs"
17
+ require_relative "path_resolve"
16
18
  require_relative "../memory_paths"
19
+ require_relative "../redacted_inspect"
17
20
 
18
21
  module Samagotchi
19
22
  module Guardrails
@@ -21,8 +24,12 @@ module Samagotchi
21
24
  # shell command, absolute paths (against Dir.pwd, as the file tools have
22
25
  # no cwd:), the directory a command runs in, its repo root, and whether
23
26
  # 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:).
27
+ # root, the cwd when there is no repo; not the call's own cwd:). A
28
+ # path whose `..` climbs out of a symlinked dir's target is in #paths
29
+ # twice: lexically and as the kernel opens it (.absolutes).
25
30
  class Targets
31
+ include RedactedInspect # @env: the call's env: may hold a secret
32
+
26
33
  SHELL_TOOLS = %w[execute task_create].freeze
27
34
  PATH_TOOLS = %w[write edit read memory_write].freeze
28
35
 
@@ -31,6 +38,24 @@ module Samagotchi
31
38
 
32
39
  attr_reader :tool, :command, :paths, :cwd, :repo_root
33
40
 
41
+ # @return [Hash{String=>String}] the variables a task_create call's
42
+ # env: sets for its command ({} for none, and for every other tool)
43
+ attr_reader :env
44
+
45
+ # Variables that change what a command's words run or where its cd
46
+ # goes, under every shell chi may use or in the programs it runs: a
47
+ # startup file (BASH_ENV, ENV), an exported function (BASH_FUNC_…), a
48
+ # cd search path, word splitting, shell options, a library loaded into
49
+ # every program, git config or a diff program that runs. A call whose
50
+ # env: sets one is never read-only (.redefines_shell?). PATH is not
51
+ # one (the tool's own description suggests it); nor are
52
+ # PROMPT_COMMAND (interactive shells only) and ZDOTDIR (zsh in sh
53
+ # emulation reads no rc files).
54
+ SHELL_REDEFINING_ENV = %w[BASH_ENV ENV CDPATH IFS SHELLOPTS BASHOPTS LD_PRELOAD LD_AUDIT
55
+ DYLD_INSERT_LIBRARIES GIT_EXTERNAL_DIFF GIT_CONFIG_PARAMETERS
56
+ GIT_CONFIG_COUNT].freeze
57
+ SHELL_REDEFINING_PREFIX = "BASH_FUNC_"
58
+
34
59
  # @return [String, nil] the call's repository (Context#repo: its
35
60
  # common git dir, shared by its worktrees): what a "this repo"
36
61
  # approval is kept for
@@ -96,15 +121,17 @@ module Samagotchi
96
121
  args = nil
97
122
  acts_as = nil
98
123
  label = nil
124
+ env = {}
99
125
  case tool
100
126
  when *SHELL_TOOLS
101
127
  command = call[:content].to_s
128
+ env = env_of(call[:env])
102
129
  given = call[:cwd].to_s.strip
103
130
  cwd = File.expand_path(given, base) unless given.empty?
104
131
  when "write", "edit"
105
- paths << absolute(call[:path], base)
132
+ paths.concat(absolutes(call[:path], base))
106
133
  when "read"
107
- paths << absolute(call[:content], base)
134
+ paths.concat(absolutes(call[:content], base))
108
135
  when "memory_write"
109
136
  paths << memory_path(call, model_key)
110
137
  else
@@ -112,7 +139,7 @@ module Samagotchi
112
139
  command = given[:command]
113
140
  given_cwd = given[:cwd].to_s.strip
114
141
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
115
- paths.concat(given[:paths].map { |path| absolute(path, cwd) })
142
+ paths.concat(given[:paths].flat_map { |path| absolutes(path, cwd) })
116
143
  entry = registry && registry[tool]
117
144
  args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
118
145
  acts_as = given[:acts_as]
@@ -121,7 +148,7 @@ module Samagotchi
121
148
  memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
122
149
  memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
123
150
  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),
151
+ new(tool: tool, command: command, paths: paths.compact, cwd: cwd, env: env, repo_root: context.repo_root(cwd),
125
152
  repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
126
153
  memory_overlay: memory_overlay, memory: memory,
127
154
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
@@ -208,15 +235,36 @@ module Samagotchi
208
235
  end
209
236
 
210
237
  # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
211
- # git hooks dir.
212
- def self.chi_dirs(context, base)
213
- require_relative "parent_approvals"
214
- ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
238
+ # git hooks dir (ChiDirs.touches).
239
+ def self.chi_dirs(context, base) = ChiDirs.touches(context, base)
240
+
241
+ # A shell call's env: as the tool reads it (an object, or JSON text
242
+ # for one): {} for none, and for one the tool would refuse (it then
243
+ # runs nothing).
244
+ def self.env_of(given)
245
+ given = JSON.parse(given) if given.is_a?(String) && !given.strip.empty?
246
+ return {} unless given.is_a?(Hash)
247
+
248
+ given.to_h { |key, value| [key.to_s, value.to_s] }.freeze
249
+ rescue JSON::ParserError
250
+ {}
251
+ end
252
+
253
+ # Whether +env+ sets a SHELL_REDEFINING_ENV variable.
254
+ def self.redefines_shell?(env)
255
+ env.each_key.any? { |key| SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) }
215
256
  end
216
257
 
217
- def self.absolute(path, base)
258
+ # +path+ (a file tool's, as Tools::ToolPath has it) absolute against
259
+ # +base+: its lexical path (File.expand_path), and the one the kernel
260
+ # opens when a `..` in it climbs out of a symlinked dir's target
261
+ # (PathResolve.expand) after it; [] for a blank path. Every check
262
+ # reads all of #paths, so it judges both.
263
+ def self.absolutes(path, base)
218
264
  path = Tools::ToolPath.normalize(path)
219
- path.empty? ? nil : File.expand_path(path, base)
265
+ return [] if path.empty?
266
+
267
+ [File.expand_path(path, base), PathResolve.expand(path, base)].uniq
220
268
  end
221
269
 
222
270
  # The file memory_write would write; nil when the call is invalid.
@@ -245,9 +293,11 @@ module Samagotchi
245
293
  end
246
294
 
247
295
  # @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)
296
+ # @param env [Hash{String=>String}] the call's env: (.env_of)
297
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, env: {}, repo: nil, args: nil, acts_as: nil,
298
+ label: nil, model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil,
299
+ memory_remove: false, memory_overlay: false, memory: nil)
300
+ @env = env
251
301
  @acts_as = acts_as
252
302
  @label = label
253
303
  @memory_remove = memory_remove
@@ -268,6 +318,28 @@ module Samagotchi
268
318
 
269
319
  def shell? = SHELL_TOOLS.include?(@tool)
270
320
 
321
+ # The homes and environments a shell call's words may resolve
322
+ # against, as [home, env] pairs: chi's own, and when the call's env:
323
+ # sets anything, that env over chi's (its HOME for ~ and a bare cd).
324
+ # A check that asks when any pair crosses only ever asks more for an
325
+ # env: than without one. Not kept (chi's env holds secrets).
326
+ def shell_envs
327
+ own = [Dir.home, ENV]
328
+ return [own] if @env.empty?
329
+
330
+ home = @env["HOME"].to_s
331
+ [own, [home.empty? ? Dir.home : home, ENV.to_h.merge(@env)]]
332
+ end
333
+
334
+ # Whether the call's env: sets a variable that changes what the
335
+ # command's words run (.redefines_shell?): such a command is never
336
+ # read-only, nor tmp-write-only.
337
+ def redefines_shell?
338
+ return @redefines_shell if defined?(@redefines_shell)
339
+
340
+ @redefines_shell = self.class.redefines_shell?(@env)
341
+ end
342
+
271
343
  # What a rule's command: reads: the shell command with the body of each
272
344
  # heredoc its command only reads as data left out (ShellLex.code_text);
273
345
  # a plugin tool's own command as given (its text may not be shell).
@@ -310,14 +382,37 @@ module Samagotchi
310
382
  # Where a shell call runs mutating git (ShellGitDirs: dirs, and
311
383
  # :unknown for one the text doesn't tell); [] for other tools.
312
384
  def git_dirs
313
- @git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
385
+ @git_dirs ||= if shell?
386
+ # chi's own GIT_DIR is not the command's concern
387
+ shell_envs.each_with_index.flat_map do |(home, vars), i|
388
+ ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
389
+ end.uniq
390
+ else
391
+ []
392
+ end
314
393
  end
315
394
 
316
395
  # Whether the command only reads (ReadOnlyShell); false without one.
317
396
  def read_only?
318
397
  return @read_only if defined?(@read_only)
319
398
 
320
- @read_only = !@command.nil? && ReadOnlyShell.read_only?(@command)
399
+ @read_only = !@command.nil? && !redefines_shell? && ReadOnlyShell.read_only?(@command)
400
+ end
401
+
402
+ # Whether the command only reads but for writes into a tmp dir that
403
+ # +root+ isn't in (ReadOnlyShell.read_only_but_writes?,
404
+ # Outside.tmp_write?): its > / >> targets, cp's destination and tee's
405
+ # files, from the call's cwd and any cd before them. The child
406
+ # boundary's; #read_only? stays what the rules read. Never for a call
407
+ # whose env: redefines the shell (#redefines_shell?); the writes must
408
+ # hold under each of #shell_envs.
409
+ # @param root [String] the folder the writes must stay out of
410
+ def read_only_but_tmp_writes?(root:)
411
+ !@command.nil? && !redefines_shell? && shell_envs.all? do |home, env|
412
+ ReadOnlyShell.read_only_but_writes?(@command, cwd: @cwd, home: home, env: env) do |path|
413
+ Outside.tmp_write?(path, root: root)
414
+ end
415
+ end
321
416
  end
322
417
 
323
418
  # A memory file that reaches the system prompt: the identity memory and
@@ -338,36 +433,43 @@ module Samagotchi
338
433
  # disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
339
434
  # loads.
340
435
  def self.prompt_memory_path?(path)
341
- real = ProtectedPaths.real(path)
342
- return false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
436
+ PathResolve.reals(path).any? do |real|
437
+ next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
343
438
 
344
- dir = File.dirname(real)
345
- dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
346
- File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
439
+ dir = File.dirname(real)
440
+ dir.casecmp?(PathResolve.real(MemoryPaths.system_dir)) ||
441
+ File.dirname(dir).casecmp?(PathResolve.real(MemoryPaths.projects_dir))
442
+ end
347
443
  rescue StandardError
348
444
  false
349
445
  end
350
446
 
351
447
  # 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.
448
+ # resolved paths, under any of #shell_envs; the text CHI_SHELL_TEXT
449
+ # for a word that can't be resolved); false without a command.
354
450
  def touches_chi?
355
451
  return @touches_chi if defined?(@touches_chi)
356
452
 
357
453
  @touches_chi = !@command.nil? && begin
358
454
  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)
455
+ dirs = @chi_dirs ? @chi_dirs.call : ParentApprovals.chi_dirs
456
+ shell_envs.any? do |home, env|
457
+ ShellPaths.touches?(@command, dirs: dirs, text: ParentApprovals::CHI_SHELL_TEXT, cwd: @cwd, home: home,
458
+ env: env, tmp_roots: Outside.tmp_roots)
459
+ end
361
460
  end
362
461
  end
363
462
 
364
463
  # 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).
464
+ # (RmTargets, under any of #shell_envs; a tmp dir the session's root
465
+ # is in doesn't count as one).
366
466
  def rm_outside_tmp?
367
467
  return @rm_outside_tmp if defined?(@rm_outside_tmp)
368
468
 
369
- @rm_outside_tmp = !@command.nil? &&
370
- RmTargets.outside_tmp?(@command, cwd: @cwd, tmp_roots: Outside.tmp_roots, session_root: @session_root)
469
+ @rm_outside_tmp = !@command.nil? && shell_envs.any? do |home, env|
470
+ RmTargets.outside_tmp?(@command, cwd: @cwd, tmp_roots: Outside.tmp_roots, session_root: @session_root,
471
+ home: home, env: env)
472
+ end
371
473
  end
372
474
 
373
475
  # Whether a shell call runs mutating git outside the session's repo
@@ -122,8 +122,9 @@ module Samagotchi
122
122
  0
123
123
  end
124
124
 
125
- # The token from its file or stdin; nil for none (a loopback chi web
126
- # needs none), false after the error line.
125
+ # The token from its file or stdin; nil for none (a chi web on
126
+ # loopback only, no LAN mode or proxy, needs none), false after the
127
+ # error line.
127
128
  def read_token(options)
128
129
  text = if options[:token_file]
129
130
  begin
@@ -145,17 +146,26 @@ module Samagotchi
145
146
  end
146
147
 
147
148
  # GET /api/info with the token: the answer, or nil after the line that
148
- # says why it can't be used.
149
+ # says why it can't be used. With no token, a chi web on the LAN or
150
+ # behind a proxy reached on its loopback (an SSH tunnel) answers chi's
151
+ # probe (its checked fields, no cwd) and every other route 401: it
152
+ # needs the token.
149
153
  def check(entry)
150
154
  reply = @transport.call(entry).call(:get, "/api/info")
151
155
  problem = SessionAccess::Remote.info_problem(entry, reply)
152
156
  return fail_line(problem.message) if problem
157
+ return fail_line(token_needed(entry)) unless reply.json.key?("cwd")
153
158
 
154
159
  reply.json
155
160
  rescue SessionAccess::RemoteFailed => e
156
161
  fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
157
162
  end
158
163
 
164
+ def token_needed(entry)
165
+ "#{entry.label}: its chi web needs its access token (its LAN link carries it, after ?token=; or its " \
166
+ "web-token file): chi remote add #{entry.name} #{entry.url} --token-file FILE"
167
+ end
168
+
159
169
  def lan_words(found, entry)
160
170
  return "loopback only" unless found["lan"]
161
171
 
@@ -25,8 +25,9 @@ module Samagotchi
25
25
  NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\z/
26
26
 
27
27
  # One remote: its +name+, chi web's +url+ (scheme, host and port, no
28
- # path) and its access +token+ (nil for a loopback chi web, which needs
29
- # none). #inspect leaves the token out.
28
+ # path) and its access +token+ (nil for a chi web on loopback only, no
29
+ # LAN mode or proxy: it alone needs none, its loopback reached through
30
+ # an SSH tunnel). #inspect leaves the token out.
30
31
  Entry = Data.define(:name, :url, :token) do
31
32
  # The host as an address, an IPv6 one bare (::1, not [::1]).
32
33
  def host = URI(url).hostname
@@ -6,6 +6,7 @@ require_relative "session_setup"
6
6
  require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
+ require_relative "guardrails/path_resolve"
9
10
  require_relative "guardrails/review_paths"
10
11
  require_relative "participant_mode"
11
12
 
@@ -185,8 +186,8 @@ module Samagotchi
185
186
  "only reads."
186
187
 
187
188
  def relative(full, root)
188
- real_root = Guardrails::ProtectedPaths.real(root)
189
- real = Guardrails::ProtectedPaths.real(full)
189
+ real_root = Guardrails::PathResolve.real(root)
190
+ real = Guardrails::PathResolve.real(full)
190
191
  real.delete_prefix(File.join(real_root, ""))
191
192
  end
192
193
  end
@@ -289,6 +289,23 @@ module Samagotchi
289
289
  data["verify"].is_a?(Hash)
290
290
  end
291
291
 
292
+ # Whether a message waits that a running turn's next boundary merges and
293
+ # that a person (or chi send, or a delegating parent) sent: an unclaimed
294
+ # input file not held for the turn's end (#turn_end_data?), with a
295
+ # prompt, from a sender that may cut (Steer.cuts?). A running task_wait
296
+ # ends on it (Worker#input_waiting?). It only peeks: nothing is claimed;
297
+ # a file the drain or a dequeue claims meanwhile (ENOENT) or one that
298
+ # doesn't parse is skipped.
299
+ def self.steer_waiting?(session_dir)
300
+ find_new_input_files(session_dir).any? do |file|
301
+ data = JSON.parse(File.read(file))
302
+ data.is_a?(Hash) && !turn_end_data?(data) && !data["prompt"].to_s.strip.empty? &&
303
+ Steer.cuts?(Steer.source_for_client(data["client_id"]))
304
+ rescue JSON::ParserError, SystemCallError
305
+ false
306
+ end
307
+ end
308
+
292
309
  # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
293
310
  # (a queue or an image message: it runs as a turn of its own, never
294
311
  # merged, #waits_for_turn_end?), :merges (a running turn's drain may
@@ -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,12 @@
2
2
 
3
3
  require_relative "../config"
4
4
  require_relative "../memory_paths"
5
- require_relative "../guardrails/protected_paths"
5
+ require_relative "../paths"
6
+ require_relative "../guardrails/chi_dirs"
7
+ require_relative "../guardrails/path_resolve"
6
8
  require_relative "../guardrails/parent_approvals"
9
+ require_relative "../guardrails/shell_lex"
10
+ require_relative "../guardrails/shell_paths"
7
11
 
8
12
  module Samagotchi
9
13
  module SystemOne
@@ -14,6 +18,21 @@ module Samagotchi
14
18
  # "System 1"). The memories dir is deliberately not one of them:
15
19
  # reading and appending a memory file stays eligible.
16
20
  #
21
+ # A file tool's paths and a shell command's words are judged against
22
+ # the same dirs (.chi_dirs: chi's whole state dir among them). The
23
+ # words are resolved as Guardrails::ShellPaths resolves them (cd
24
+ # chains, ~, $HOME and the XDG dirs, symlinks, `..` both ways), under
25
+ # chi's env and the call's env: when the caller hands its Targets; one
26
+ # landing in chi's dirs outside the memories refuses.
27
+ # The memories text is taken out of the command (the exemption) only
28
+ # when its text shows no climb (CLIMB: a `..`, a `$`, backticks, a `~`
29
+ # other than the home one, `cd -`) and every word either resolved or is
30
+ # data (Words#plain: a script or heredoc body, a phrase, a glob with no
31
+ # `/`) read while the dir was known. A word the walk can't follow
32
+ # otherwise (`${PWD%/*}`, `$(dirname $PWD)`, `cd -`) may climb out of the
33
+ # memories dir without saying so, so its memories text stays in and the
34
+ # text check refuses it.
35
+ #
17
36
  # Pure: it only reads the paths as this process's environment names them,
18
37
  # and answers on the approval's own facts (Guardrails::Approval.payload's
19
38
  # approval:), the same shape ParentApprovals.protected_facts? takes.
@@ -29,50 +48,138 @@ module Samagotchi
29
48
  # The characters that end a shell word: a memories path in a command
30
49
  # ends at the first one.
31
50
  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)}
51
+
52
+ # A climb out of a dir as a command's text may spell it: a `..`
53
+ # segment (`cd ..;`, `../x`, inside a script body too), any `$`
54
+ # expansion or backticks, a `~` that isn't the home one (`~-`, `~+`,
55
+ # `~user`), or `cd -` / `pushd -`.
56
+ CLIMB = Regexp.union(
57
+ /(?<![\w.])\.\.(?![\w.])/, /[$`]/, %r{(?:\A|[\s=:'"(])~(?![/\s'";&|)]|\z)},
58
+ /(?:\A|[\s;&|(])(?:cd|pushd)\s+-(?=[\s;&|)]|\z)/
59
+ )
60
+
61
+ # What resolving a command's words found: whether one lands in chi's
62
+ # dirs (outside the memories dir, or in its installed bundles), and
63
+ # whether every word resolved or is data (#plain).
64
+ Words = Data.define(:chi, :plain)
36
65
 
37
66
  module_function
38
67
 
39
68
  # @param approval [Hash, nil] the ask's approval facts (rule, tool,
40
- # paths, command)
69
+ # paths, command, cwd)
70
+ # @param targets [Guardrails::Targets, nil] the call's targets, for its
71
+ # env: (Targets#shell_envs, #redefines_shell?)
41
72
  # @return [Boolean] whether System 1 must not answer this ask
42
- def refuse?(approval)
73
+ def refuse?(approval, targets: nil)
43
74
  return false unless approval.is_a?(Hash)
44
75
  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))
76
+ # An env: that redefines the shell (BASH_ENV, BASH_FUNC_…) runs what
77
+ # no word of the command says.
78
+ return true if targets&.redefines_shell?
48
79
 
49
80
  command = fetch(approval, :command).to_s
81
+ words = command.empty? ? nil : words(command, cwd: fetch(approval, :cwd), targets: targets)
82
+ return true if words&.chi
83
+ return true if chi_paths?(approval)
84
+ # The test that makes an ask user_only (ParentApprovals#chi_files?:
85
+ # chi's dirs as the command spells them), minus the memories.
86
+ return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval, exempt: exempt?(command, words)))
87
+
50
88
  command.match?(DEFAULT_STATE_TEXT)
51
89
  end
52
90
 
91
+ # Whether a path of the approval lands in chi's dirs (#chi_dirs: its
92
+ # whole state dir too, as a command's words are judged) outside the
93
+ # memories dir, or in its installed bundles.
94
+ def chi_paths?(approval)
95
+ memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
96
+ bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
97
+ dirs = chi_dirs
98
+ Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?).any? do |path|
99
+ Guardrails::PathResolve.reals(path).any? do |real|
100
+ dirs.any? { |dir| Guardrails::PathResolve.within?(real, dir) } && !memory?(real, memories, bundles)
101
+ end
102
+ end
103
+ end
104
+
53
105
  # The approval's facts without what is in the memories dir (not its
54
106
  # installed bundles): the paths under it, and its spellings in the
55
- # command unless the command climbs with `..`.
56
- def beyond_memories(approval)
57
- memories = Guardrails::ProtectedPaths.real(MemoryPaths.system_dir)
58
- bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
107
+ # command when +exempt+ (.exempt?).
108
+ def beyond_memories(approval, exempt:)
109
+ memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
110
+ bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
59
111
  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)
112
+ Guardrails::PathResolve.reals(path).all? { |real| memory?(real, memories, bundles) }
62
113
  end
63
114
  command = fetch(approval, :command).to_s
64
- unless command.match?(CLIMB)
65
- command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" }
66
- end
115
+ command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
67
116
  { paths: paths, command: command }
68
117
  end
69
118
 
119
+ # Whether the command's memories text may be taken out: no command,
120
+ # or one whose text shows no climb (CLIMB) and whose words are plain.
121
+ def exempt?(command, words)
122
+ words.nil? || (words.plain && !command.match?(CLIMB))
123
+ end
124
+
125
+ # The command's words resolved (ShellPaths::Walk) from +cwd+ (else
126
+ # this process's dir) under each of the call's shell envs: Words.
127
+ def words(command, cwd:, targets:)
128
+ memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
129
+ bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
130
+ dirs = chi_dirs
131
+ tokens = Guardrails::ShellLex.lex(command)
132
+ plain = !Guardrails::ShellLex.unterminated?(command) &&
133
+ tokens.none? { |_, value| value.include?(Guardrails::ShellLex::SUBST) }
134
+ chi = false
135
+ commands = Guardrails::ShellLex.simple_commands(tokens)
136
+ envs = targets ? targets.shell_envs : [[Dir.home, ENV]]
137
+ envs.each do |home, env|
138
+ walk = Guardrails::ShellPaths::Walk.new(cwd.to_s.empty? ? Dir.pwd : cwd.to_s, home, env)
139
+ commands.each do |simple|
140
+ # A cd the walk couldn't follow: no later word is data.
141
+ known = !simple.is_a?(Array) || !walk.paths(".").empty?
142
+ walk.step(simple).each do |word, path|
143
+ next plain &&= known && data?(word) unless path
144
+
145
+ chi ||= Guardrails::PathResolve.reals(path).any? do |real|
146
+ dirs.any? { |dir| Guardrails::PathResolve.under?(real, dir) } && !memory?(real, memories, bundles)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ Words.new(chi: chi, plain: plain)
152
+ rescue ArgumentError, EncodingError
153
+ Words.new(chi: false, plain: false)
154
+ end
155
+
156
+ # Whether a word the walk couldn't resolve is data rather than a path
157
+ # spelt some other way: a heredoc body, a script or a phrase (spaces,
158
+ # quotes, operators: `python3 -c '…'`, `'- a note'`), or a glob with
159
+ # no `/`. A `$` or `~` left in it is not (CLIMB refuses those anyway).
160
+ def data?(word)
161
+ return true if word == Guardrails::ShellLex::HEREDOC
162
+ return false if word.include?("$") || word.include?("~")
163
+ return true if word.match?(Guardrails::ShellPaths::SCRIPT)
164
+
165
+ word.match?(Guardrails::ShellPaths::GLOB) && !word.include?("/")
166
+ end
167
+
168
+ # chi's own dirs (ParentApprovals.chi_dirs) and its whole state dir
169
+ # (DEFAULT_STATE_TEXT's), resolved (Guardrails::ChiDirs.self_protect).
170
+ def chi_dirs = Guardrails::ChiDirs.self_protect
171
+
172
+ # Whether +real+ is in the memories dir and not in its installed bundles.
173
+ def memory?(real, memories, bundles)
174
+ Guardrails::PathResolve.within?(real, memories) && !Guardrails::PathResolve.within?(real, bundles)
175
+ end
176
+
70
177
  # The memories dir as a command spells it: the default
71
178
  # `.config/samagotchi/memories`, or this process's own path, with the
72
179
  # rest of that shell word.
73
180
  def memories_text
74
181
  dir = ConfigFile.config_dir
75
- prefixes = [".config/samagotchi", dir, Guardrails::ProtectedPaths.real(dir)]
182
+ prefixes = [".config/samagotchi", dir, Guardrails::PathResolve.real(dir)]
76
183
  .map { |prefix| Regexp.escape(prefix.to_s.chomp("/")) }.uniq
77
184
  word = WORD_END.sub("[", "[^")
78
185
  Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{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"
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  id = @session_id
58
58
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
59
59
 
60
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
60
+ link = WorkerLink.resolve(session_dir, unlink: true)
61
61
  if link.is_a?(WorkerLink::Skew)
62
62
  @failure = link.message(id)
63
63
  return [:gone, "worker_newer"]
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  candidate = if found&.port == port
80
80
  found
81
81
  else
82
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
82
+ link = WorkerLink.resolve(session_dir, unlink: true)
83
83
  link.live? && link.port == port ? link.client(@session_id, host: @host) : nil
84
84
  end
85
85
  @client = candidate if candidate