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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- 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 "
|
|
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
|
|
132
|
+
paths.concat(absolutes(call[:path], base))
|
|
106
133
|
when "read"
|
|
107
|
-
paths
|
|
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].
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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?
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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?
|
|
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
|
-
|
|
342
|
-
|
|
436
|
+
PathResolve.reals(path).any? do |real|
|
|
437
|
+
next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
|
|
343
438
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
-
# needs none), false after the
|
|
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
|
|
data/lib/samagotchi/remotes.rb
CHANGED
|
@@ -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
|
|
29
|
-
# none
|
|
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::
|
|
189
|
-
real = Guardrails::
|
|
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?(
|
|
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) && !
|
|
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?(
|
|
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
|
|
1494
|
-
#
|
|
1495
|
-
#
|
|
1496
|
-
private_class_method def self.
|
|
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 "../
|
|
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
|
-
|
|
33
|
-
# climb
|
|
34
|
-
#
|
|
35
|
-
|
|
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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
return true if
|
|
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
|
|
56
|
-
def beyond_memories(approval)
|
|
57
|
-
memories = Guardrails::
|
|
58
|
-
bundles = Guardrails::
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
|
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
|
|
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
|