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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/docs/architecture.md +12 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +150 -25
- data/docs/internals/participants.md +17 -3
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
- data/lib/samagotchi/guardrails/outside.rb +54 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
- data/lib/samagotchi/guardrails/pair_command.rb +261 -66
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +22 -8
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
- data/lib/samagotchi/guardrails/targets.rb +128 -24
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- 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/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- 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
|
|
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
|
-
|
|
67
|
-
|
|
67
|
+
ProtectedPaths.reals(path).any? do |real|
|
|
68
|
+
next true if roots.any? { |root| within?(real, root) }
|
|
68
69
|
|
|
69
|
-
|
|
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
|
-
@
|
|
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 << @
|
|
106
|
+
when :open then @subshells << @dirs
|
|
92
107
|
[]
|
|
93
|
-
when :close then @
|
|
108
|
+
when :close then @dirs = @subshells.pop || @dirs
|
|
94
109
|
[]
|
|
95
110
|
else command(words)
|
|
96
111
|
end
|
|
97
112
|
end
|
|
98
113
|
|
|
99
|
-
#
|
|
100
|
-
def
|
|
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
|
-
|
|
107
|
-
|
|
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 @
|
|
145
|
+
when "cd" then @dirs = cd_args.empty? ? [@home] : dirs_after(cd_args.first)
|
|
110
146
|
when "pushd"
|
|
111
|
-
@pushed << @
|
|
112
|
-
@
|
|
113
|
-
when "popd" then @
|
|
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
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
131
|
+
paths.concat(absolutes(call[:path], base))
|
|
106
132
|
when "read"
|
|
107
|
-
paths
|
|
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].
|
|
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
|
-
|
|
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?
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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?
|
|
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
|
-
|
|
342
|
-
|
|
438
|
+
ProtectedPaths.reals(path).any? do |real|
|
|
439
|
+
next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
|
|
343
440
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
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?(
|
|
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,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
|
-
|
|
33
|
-
# climb
|
|
34
|
-
#
|
|
35
|
-
|
|
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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
return true if
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|