samagotchi 0.127.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 +106 -1
- data/docs/architecture.md +15 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +155 -25
- data/docs/internals/participants.md +23 -5
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +56 -0
- 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 +239 -9
- 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 +13 -14
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
- 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/own_turns.rb +81 -0
- data/lib/samagotchi/verify_anchor.rb +8 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- data/lib/samagotchi/web/participant_review.rb +4 -2
- data/lib/samagotchi/web/public/annotate_bar.js +14 -2
- data/lib/samagotchi/web/public/app.js +25 -5
- data/lib/samagotchi/web/public/composer.css +1 -0
- data/lib/samagotchi/web/public/notify.js +2 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +18 -7
- data/lib/samagotchi/web/public/participant_model.js +3 -2
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/question_card.js +4 -1
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -2
- data/lib/samagotchi/web/public/verdict_card.js +18 -3
- data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
- data/lib/samagotchi/web/public/verify_card.css +1 -0
- data/lib/samagotchi/web/public/verify_preset.js +6 -0
- data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
- 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 +2 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "strscan"
|
|
4
4
|
require_relative "shell_lex"
|
|
5
|
+
require_relative "shell_paths"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Guardrails
|
|
@@ -17,6 +18,17 @@ module Samagotchi
|
|
|
17
18
|
# $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
|
|
18
19
|
# expansion, a < or << redirection, a path to a verb (/bin/ls), and
|
|
19
20
|
# less/more/vim (they run commands) are all not read-only.
|
|
21
|
+
#
|
|
22
|
+
# .read_only_but_writes? (the child boundary's) also lets through the
|
|
23
|
+
# writes it can name: a > or >> target, cp's destination and tee's
|
|
24
|
+
# files, each resolved against the dir the command is in by then
|
|
25
|
+
# (ShellPaths::Walk) and accepted by the caller's block. It trusts
|
|
26
|
+
# Walk's dir only where the shell's can't differ: a cd or pushd takes
|
|
27
|
+
# one existing dir, spelt /…, . or .. (no option, +N, -, $, ~ or
|
|
28
|
+
# CDPATH name), and joins beside it are && only (a failed or skipped
|
|
29
|
+
# cd can't be followed by a command that still runs); popd is never
|
|
30
|
+
# accepted, nor is a $ or ~ in a write's target (the call's env may
|
|
31
|
+
# set it).
|
|
20
32
|
module ReadOnlyShell
|
|
21
33
|
# Operators that join read-only commands (no &, no ;;).
|
|
22
34
|
JOINS = ["&&", "||", ";", "|", "|&", "\n", "(", ")"].freeze
|
|
@@ -29,6 +41,49 @@ module Samagotchi
|
|
|
29
41
|
# target follows).
|
|
30
42
|
NULL_REDIRECT = %r{\A\d*>>?/dev/null\z}
|
|
31
43
|
BARE_REDIRECT = /\A\d*>>?\z/
|
|
44
|
+
# A redirection with its target in the same word (>out, 2>>out); not
|
|
45
|
+
# >&file (it is &> spelt the other way).
|
|
46
|
+
REDIRECT_TO = /\A\d*>>?([^&>].*)\z/m
|
|
47
|
+
# Pipe operators: each side runs in a subshell.
|
|
48
|
+
PIPES = ["|", "|&"].freeze
|
|
49
|
+
# Verbs that change the dir ShellPaths::Walk follows.
|
|
50
|
+
DIR_VERBS = %w[cd pushd popd].freeze
|
|
51
|
+
# The joins a group (or the whole command) holding a cd or pushd may
|
|
52
|
+
# use in write mode: after any other a command can run in a dir the
|
|
53
|
+
# cd didn't reach (cd failed, or was skipped by ||).
|
|
54
|
+
DIR_JOINS = ["&&", "|", "|&"].freeze
|
|
55
|
+
# A dir verb's operand the shell resolves as Walk does: absolute, or
|
|
56
|
+
# ./ ../ (CDPATH is not searched for these).
|
|
57
|
+
DIR_OPERAND = %r{\A(?:/|\.\.?(?:/|\z))}
|
|
58
|
+
|
|
59
|
+
# The write side of .read_only_but_writes?: the Walk that follows the
|
|
60
|
+
# command's dir and the caller's test for an absolute path it writes.
|
|
61
|
+
Writes = Data.define(:walk, :writable) do
|
|
62
|
+
# +word+ as absolute paths against the current dir (ShellPaths::Walk:
|
|
63
|
+
# its lexical one, and its physical one when they differ, last); []
|
|
64
|
+
# when it can't be resolved, or has a $ or ~ (the shell may expand
|
|
65
|
+
# them from an env Walk doesn't see, as task_create's env: sets).
|
|
66
|
+
def paths(word) = word.include?("$") || word.start_with?("~") ? [] : walk.paths(word)
|
|
67
|
+
|
|
68
|
+
# Whether a cd or pushd with +args+ lands where Walk says: one
|
|
69
|
+
# operand (DIR_OPERAND) naming an existing dir every way it
|
|
70
|
+
# resolves. popd never does.
|
|
71
|
+
def dir_step?(verb, args)
|
|
72
|
+
return false unless verb != "popd" && args.size == 1 && args.first.match?(DIR_OPERAND)
|
|
73
|
+
|
|
74
|
+
dirs = paths(args.first)
|
|
75
|
+
!dirs.empty? && dirs.all? { |dir| File.directory?(dir) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ok?(path) = path ? writable.call(path) == true : false
|
|
79
|
+
|
|
80
|
+
# Whether +word+ names a file the command may write, every way it
|
|
81
|
+
# resolves.
|
|
82
|
+
def file?(word)
|
|
83
|
+
paths = paths(word)
|
|
84
|
+
!paths.empty? && paths.all? { |path| ok?(path) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
32
87
|
|
|
33
88
|
# Verbs that only read whatever their options: → nil. The others
|
|
34
89
|
# check their arguments.
|
|
@@ -49,29 +104,104 @@ module Samagotchi
|
|
|
49
104
|
false
|
|
50
105
|
end
|
|
51
106
|
|
|
107
|
+
# Whether the command only reads but for writes whose resolved paths
|
|
108
|
+
# the block accepts: > and >> targets, cp's destination (and the
|
|
109
|
+
# files it makes in it) and tee's files. Paths resolve against +cwd+
|
|
110
|
+
# and the dir an earlier cd, pushd or popd moved to; a cd beside a pipe
|
|
111
|
+
# (a subshell's) isn't one Walk can follow, so it is not.
|
|
112
|
+
# @param command [String, nil]
|
|
113
|
+
# @param cwd [String, nil] where the command starts
|
|
114
|
+
# @yieldparam path [String] an absolute path, symlinks not resolved
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def read_only_but_writes?(command, cwd:, home: Dir.home, env: ENV, &writable)
|
|
117
|
+
text = command.to_s
|
|
118
|
+
return false if text.strip.empty?
|
|
119
|
+
|
|
120
|
+
tokens = ShellLex.lex(text)
|
|
121
|
+
commands = split(tokens, marks: true) or return false
|
|
122
|
+
return false if commands.none?(Array) || !dir_joins_ok?(tokens)
|
|
123
|
+
|
|
124
|
+
writes = Writes.new(walk: ShellPaths::Walk.new(cwd, home, env), writable: writable)
|
|
125
|
+
commands.all? { |words| words.is_a?(Symbol) ? writes.walk.step(words) : simple_read_only?(words, writes) }
|
|
126
|
+
rescue ArgumentError, EncodingError
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
52
130
|
# The simple commands as [text, specials] word lists; nil when an
|
|
53
|
-
# operator isn't one read-only commands may be joined with.
|
|
54
|
-
|
|
131
|
+
# operator isn't one read-only commands may be joined with. With
|
|
132
|
+
# +marks+, ( and ) stay as :open / :close, and a cd, pushd or popd
|
|
133
|
+
# beside a pipe is nil.
|
|
134
|
+
def split(tokens, marks: false)
|
|
55
135
|
commands = [[]]
|
|
136
|
+
piped = false
|
|
56
137
|
tokens.each do |kind, value, specials|
|
|
57
138
|
if kind == :op
|
|
58
139
|
return nil unless JOINS.include?(value)
|
|
59
140
|
|
|
141
|
+
pipe = PIPES.include?(value)
|
|
142
|
+
return nil if marks && (piped || pipe) && dir_verb?(commands.last)
|
|
143
|
+
|
|
144
|
+
piped = pipe
|
|
145
|
+
commands << (value == "(" ? :open : :close) if marks && ["(", ")"].include?(value)
|
|
60
146
|
commands << []
|
|
61
147
|
else
|
|
62
148
|
commands.last << [value, specials]
|
|
63
149
|
end
|
|
64
150
|
end
|
|
65
|
-
commands.
|
|
151
|
+
return nil if marks && piped && dir_verb?(commands.last)
|
|
152
|
+
|
|
153
|
+
commands.reject { |words| words.is_a?(Array) && words.empty? }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dir_verb?(words) = words.is_a?(Array) && DIR_VERBS.include?(verb_of(words))
|
|
157
|
+
|
|
158
|
+
# Whether each group holding a dir verb itself (the whole command,
|
|
159
|
+
# or a ( … ): nested ones are their own) joins with DIR_JOINS only.
|
|
160
|
+
def dir_joins_ok?(tokens)
|
|
161
|
+
scopes = [[false, false]] # [holds a dir verb, has another join]
|
|
162
|
+
words = []
|
|
163
|
+
tokens.each do |kind, value, specials|
|
|
164
|
+
next words << [value, specials] unless kind == :op
|
|
165
|
+
|
|
166
|
+
scopes.last[0] ||= dir_verb?(words)
|
|
167
|
+
words = []
|
|
168
|
+
case value
|
|
169
|
+
when "(" then scopes << [false, false]
|
|
170
|
+
when ")"
|
|
171
|
+
dir, other = scopes.pop
|
|
172
|
+
return false if scopes.empty? || (dir && other)
|
|
173
|
+
else scopes.last[1] ||= !DIR_JOINS.include?(value)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
scopes.last[0] ||= dir_verb?(words)
|
|
177
|
+
scopes.none? { |dir, other| dir && other }
|
|
66
178
|
end
|
|
67
179
|
|
|
68
|
-
|
|
180
|
+
# The first word that is no redirection or its target.
|
|
181
|
+
def verb_of(words)
|
|
182
|
+
target = false
|
|
183
|
+
words.each do |text, specials|
|
|
184
|
+
if target then target = false
|
|
185
|
+
elsif specials.include?(">") then target = text.match?(BARE_REDIRECT)
|
|
186
|
+
else return text
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# With +writes+ (.read_only_but_writes?) its redirections may write
|
|
193
|
+
# what it accepts, cp and tee count, and the walk takes the command.
|
|
194
|
+
def simple_read_only?(words, writes = nil)
|
|
69
195
|
return false if words.any? { |text, specials| text.include?(ShellLex::SUBST) || !expansion_ok?(text, specials) }
|
|
70
196
|
|
|
71
|
-
args = strip_redirects(words) or return false
|
|
197
|
+
args = strip_redirects(words, writes) or return false
|
|
198
|
+
return false if writes && DIR_VERBS.include?(args.first) && !writes.dir_step?(args.first, args.drop(1))
|
|
199
|
+
|
|
200
|
+
writes&.walk&.step(args)
|
|
72
201
|
verb = args.shift
|
|
73
202
|
return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
|
|
74
203
|
return true if PLAIN.include?(verb)
|
|
204
|
+
return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
|
|
75
205
|
|
|
76
206
|
check = CHECKS[verb] or return false
|
|
77
207
|
check.call(args)
|
|
@@ -85,19 +215,20 @@ module Samagotchi
|
|
|
85
215
|
end
|
|
86
216
|
|
|
87
217
|
# The words with their redirections taken out; nil when one isn't an
|
|
88
|
-
# fd dup
|
|
89
|
-
def strip_redirects(words)
|
|
218
|
+
# fd dup, to /dev/null, or (with +writes+) to a file it accepts.
|
|
219
|
+
def strip_redirects(words, writes = nil)
|
|
90
220
|
out = []
|
|
91
221
|
pending = false
|
|
92
222
|
words.each do |text, specials|
|
|
93
223
|
if pending
|
|
94
|
-
return nil unless text == "/dev/null"
|
|
224
|
+
return nil unless text == "/dev/null" || (writes && specials.delete("$").empty? && writes.file?(text))
|
|
95
225
|
|
|
96
226
|
pending = false
|
|
97
227
|
elsif specials.include?("<")
|
|
98
228
|
return nil
|
|
99
229
|
elsif specials.include?(">")
|
|
100
230
|
next if text.match?(FD_DUP) || text.match?(NULL_REDIRECT)
|
|
231
|
+
next if writes && (m = text.match(REDIRECT_TO)) && !m[1].match?(/[<>]/) && writes.file?(m[1])
|
|
101
232
|
return nil unless text.match?(BARE_REDIRECT)
|
|
102
233
|
|
|
103
234
|
pending = true
|
|
@@ -130,6 +261,53 @@ module Samagotchi
|
|
|
130
261
|
# uniq IN OUT writes OUT.
|
|
131
262
|
def uniq(args) = args.reject { |a| a.start_with?("-") }.size <= 1
|
|
132
263
|
|
|
264
|
+
SQLITE_OPTIONS = %w[-readonly -safe -json -csv -header -noheader -line -column -box -list -separator -bail
|
|
265
|
+
-batch -noinit].freeze
|
|
266
|
+
|
|
267
|
+
# sqlite3 can still write or run programs unless both safety flags are
|
|
268
|
+
# present. Its options may follow the database name, so check every word.
|
|
269
|
+
def sqlite3(args)
|
|
270
|
+
options = []
|
|
271
|
+
positional = []
|
|
272
|
+
index = 0
|
|
273
|
+
while index < args.length
|
|
274
|
+
arg = args[index]
|
|
275
|
+
if arg == "-separator"
|
|
276
|
+
return false if index + 1 == args.length
|
|
277
|
+
|
|
278
|
+
index += 2
|
|
279
|
+
elsif SQLITE_OPTIONS.include?(arg)
|
|
280
|
+
options << arg
|
|
281
|
+
index += 1
|
|
282
|
+
elsif arg.start_with?("-", ".")
|
|
283
|
+
return false
|
|
284
|
+
else
|
|
285
|
+
positional << arg
|
|
286
|
+
index += 1
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
options.include?("-readonly") && options.include?("-safe") && positional.size >= 2
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
GH_READS = {
|
|
294
|
+
"run" => %w[list view],
|
|
295
|
+
"pr" => %w[list view diff checks],
|
|
296
|
+
"issue" => %w[list view],
|
|
297
|
+
"repo" => %w[view]
|
|
298
|
+
}.freeze
|
|
299
|
+
|
|
300
|
+
def gh(args)
|
|
301
|
+
# -w alone or in a bundle of short flags (-cw) opens a browser.
|
|
302
|
+
return false if args.any? { |arg| arg.match?(/\A-[a-zA-Z]*w/) || arg.start_with?("--web") }
|
|
303
|
+
|
|
304
|
+
group, action = args
|
|
305
|
+
return false unless GH_READS.fetch(group, []).include?(action)
|
|
306
|
+
return false if group == "pr" && action == "checks" && args.any? { |arg| arg.start_with?("--watch") }
|
|
307
|
+
|
|
308
|
+
true
|
|
309
|
+
end
|
|
310
|
+
|
|
133
311
|
SED_FLAGS = /\A-[nErsuz]+\z/
|
|
134
312
|
SED_LONG = %w[--quiet --silent --regexp-extended --separate --unbuffered --null-data --posix --debug
|
|
135
313
|
--sandbox].freeze
|
|
@@ -225,10 +403,62 @@ module Samagotchi
|
|
|
225
403
|
names.zero? || options.any? { |a| GIT_BRANCH_LISTING.any? { |listing| a == listing || a.start_with?("#{listing}=") } }
|
|
226
404
|
end
|
|
227
405
|
|
|
406
|
+
CP_SHORT = "afinpvrRPLHxT"
|
|
407
|
+
CP_LONG = %w[--archive --force --interactive --no-clobber --recursive --verbose --dereference
|
|
408
|
+
--no-dereference --one-file-system --no-target-directory].freeze
|
|
409
|
+
CP_PRESERVE = /\A--(?:no-)?preserve(?:=[\w,]+)?\z/
|
|
410
|
+
|
|
411
|
+
# cp whose destination (the last argument, or -t / --target-directory)
|
|
412
|
+
# and the files it makes in it, when it is a dir, the caller accepts.
|
|
413
|
+
# Only options that copy: no -l/-s (links into the source), --parents
|
|
414
|
+
# or --backup; anything unknown is not.
|
|
415
|
+
def cp(args, writes)
|
|
416
|
+
target = nil
|
|
417
|
+
no_target = false
|
|
418
|
+
positional = []
|
|
419
|
+
rest = args.dup
|
|
420
|
+
until rest.empty?
|
|
421
|
+
arg = rest.shift
|
|
422
|
+
if !arg.start_with?("-") || arg == "-" then positional << arg
|
|
423
|
+
elsif arg == "--" then positional.concat(rest.shift(rest.size))
|
|
424
|
+
elsif (m = arg.match(/\A-([#{CP_SHORT}]*)t(.*)\z/m))
|
|
425
|
+
no_target ||= m[1].include?("T")
|
|
426
|
+
target = m[2].empty? ? rest.shift : m[2]
|
|
427
|
+
return false unless target
|
|
428
|
+
elsif arg == "--target-directory" then target = rest.shift or return false
|
|
429
|
+
elsif (m = arg.match(/\A--target-directory=(.+)\z/m)) then target = m[1]
|
|
430
|
+
elsif arg.match?(/\A-[#{CP_SHORT}]+\z/) then no_target ||= arg.include?("T")
|
|
431
|
+
elsif CP_LONG.include?(arg) || arg.match?(CP_PRESERVE) then no_target ||= arg == "--no-target-directory"
|
|
432
|
+
else return false
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
into = !target.nil?
|
|
436
|
+
target ||= positional.pop
|
|
437
|
+
return false if positional.empty? || (into && no_target)
|
|
438
|
+
|
|
439
|
+
dests = writes.paths(target)
|
|
440
|
+
return false if dests.empty?
|
|
441
|
+
|
|
442
|
+
into ||= !no_target && File.directory?(dests.last)
|
|
443
|
+
made = into ? dests.product(positional).map { |dest, src| File.join(dest, File.basename(src)) } : []
|
|
444
|
+
[*dests, *made].all? { |path| writes.ok?(path) }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
TEE_OPTIONS = %w[-a --append -i --ignore-interrupts -p].freeze
|
|
448
|
+
|
|
449
|
+
# tee whose every file the caller accepts.
|
|
450
|
+
def tee(args, writes)
|
|
451
|
+
files = args.reject { |arg| TEE_OPTIONS.include?(arg) }
|
|
452
|
+
files.all? { |file| !file.start_with?("-") && writes.file?(file) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The verbs that write, for .read_only_but_writes? only: → (args, writes).
|
|
456
|
+
WRITE_CHECKS = { "cp" => method(:cp), "tee" => method(:tee) }.freeze
|
|
457
|
+
|
|
228
458
|
CHECKS = {
|
|
229
459
|
"rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
|
|
230
460
|
"sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
|
|
231
|
-
"git" => method(:git)
|
|
461
|
+
"git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
|
|
232
462
|
}.freeze
|
|
233
463
|
|
|
234
464
|
# A sed script parsed far enough to tell that it only reads: its
|
|
@@ -10,9 +10,11 @@ module Samagotchi
|
|
|
10
10
|
# under the session's project root that isn't on the read denylist
|
|
11
11
|
# (chi's state and config dirs, home secret dirs, project secret files),
|
|
12
12
|
# checked on the path as given and as it resolves (a symlink in the
|
|
13
|
-
# project to ~/.ssh is ~/.ssh
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
|
|
14
|
+
# of its target, as the kernel opens it: ProtectedPaths.reals).
|
|
15
|
+
# ReviewBoundary asks it for every read of a participant's turn; a
|
|
16
|
+
# review session's subjects (chi review, the participant page's Plan)
|
|
17
|
+
# go through the same check.
|
|
16
18
|
class ReviewPaths
|
|
17
19
|
# Home folders that hold keys and tokens, never read in a review turn
|
|
18
20
|
# (nor read or written in a pair turn).
|
|
@@ -46,6 +48,17 @@ module Samagotchi
|
|
|
46
48
|
# chi's state dir and config dir, as Paths has them now.
|
|
47
49
|
def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
|
|
48
50
|
|
|
51
|
+
# Whether +root+ is the home folder or a folder above it (symlinks
|
|
52
|
+
# resolved on both): no project to keep a participant's turn in. A
|
|
53
|
+
# review turn reads nothing there (#refusal: HOME_ROOT); a pair turn
|
|
54
|
+
# asks the owner before every write (PairBoundary).
|
|
55
|
+
# @param root [String] @param home [String, nil]
|
|
56
|
+
def self.home_root?(root, home)
|
|
57
|
+
return false if home.to_s.empty? || root.to_s.empty?
|
|
58
|
+
|
|
59
|
+
ShellPaths.within?(ProtectedPaths.real(home.to_s), ProtectedPaths.real(root.to_s))
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
# @param root [String, nil] the session's project root (the work tree
|
|
50
63
|
# top of its folder); nil or blank refuses every path
|
|
51
64
|
# @param denied_dirs [Array<String>] chi's own dirs (state, config:
|
|
@@ -66,14 +79,15 @@ module Samagotchi
|
|
|
66
79
|
def refusal(paths)
|
|
67
80
|
return OUTSIDE if paths.empty? || @root.empty?
|
|
68
81
|
|
|
82
|
+
return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
|
|
83
|
+
|
|
69
84
|
real_root = ProtectedPaths.real(@root)
|
|
70
|
-
return HOME_ROOT if !@home.empty? && ShellPaths.within?(ProtectedPaths.real(@home), real_root)
|
|
71
85
|
|
|
72
86
|
paths.each do |path|
|
|
73
87
|
given = File.expand_path(path)
|
|
74
|
-
|
|
75
|
-
return OUTSIDE unless ShellPaths.within?(real, real_root)
|
|
76
|
-
return SECRET if secret?(given) || secret?(real)
|
|
88
|
+
reals = ProtectedPaths.reals(path)
|
|
89
|
+
return OUTSIDE unless reals.all? { |real| ShellPaths.within?(real, real_root) }
|
|
90
|
+
return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
|
|
77
91
|
end
|
|
78
92
|
nil
|
|
79
93
|
end
|
|
@@ -86,7 +100,7 @@ module Samagotchi
|
|
|
86
100
|
def secret_refusal(paths)
|
|
87
101
|
paths.each do |path|
|
|
88
102
|
given = File.expand_path(path)
|
|
89
|
-
return SECRET if secret?(given) || secret?(
|
|
103
|
+
return SECRET if secret?(given) || ProtectedPaths.reals(path).any? { |real| secret?(real) }
|
|
90
104
|
end
|
|
91
105
|
nil
|
|
92
106
|
end
|
|
@@ -61,9 +61,10 @@ module Samagotchi
|
|
|
61
61
|
return false unless options.any? { |o| o.match?(RECURSIVE) } && options.any? { |o| o.match?(FORCE) }
|
|
62
62
|
|
|
63
63
|
targets.empty? || targets.any? do |target|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
paths = walk.paths(target)
|
|
65
|
+
paths.empty? || paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
|
|
66
|
+
roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
|
|
67
|
+
end
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -33,7 +33,9 @@ module Samagotchi
|
|
|
33
33
|
targets = verdict.targets
|
|
34
34
|
return false unless targets && %w[write edit].include?(targets.tool)
|
|
35
35
|
|
|
36
|
-
targets.paths.
|
|
36
|
+
targets.paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
|
|
37
|
+
ProtectedPaths.within?(real, @memories_dir.call)
|
|
38
|
+
end
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
end
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "shell_lex"
|
|
4
|
+
require_relative "protected_paths"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module Guardrails
|
|
7
8
|
# Where a shell command runs a git subcommand that changes a checkout
|
|
8
9
|
# (commit, add, reset, …): the directories, or :unknown for one that
|
|
9
|
-
# can't be read from the text ($VAR, $(…), `cd -`).
|
|
10
|
-
# follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
|
|
11
|
-
# and GIT_DIR/GIT_WORK_TREE.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# can't be read from the text ($VAR, $(…), `cd -`). Read from the text:
|
|
11
|
+
# it follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
|
|
12
|
+
# and GIT_DIR/GIT_WORK_TREE. Where a `..` climbs out of a symlinked
|
|
13
|
+
# dir's target, its lexical path and its physical one
|
|
14
|
+
# (ProtectedPaths.expand, the only look at the disk) both count: a cd
|
|
15
|
+
# may land at either (the shell's logical cd chdirs to the physical
|
|
16
|
+
# path when the logical one doesn't exist, and always with -P), so the
|
|
17
|
+
# walk keeps every dir the command may be in, and git's -C and dir
|
|
18
|
+
# options are opened the kernel's way; such a git counts from each. It
|
|
19
|
+
# catches honest mistakes (cd into another checkout and commit
|
|
20
|
+
# there); sh -c, scripts, aliases and $(…) bodies get through. Never
|
|
21
|
+
# raises on odd input.
|
|
14
22
|
module ShellGitDirs
|
|
15
23
|
MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
|
|
16
24
|
cherry-pick revert pull restore am].freeze
|
|
@@ -22,9 +30,11 @@ module Samagotchi
|
|
|
22
30
|
# @param command [String] the shell command
|
|
23
31
|
# @param cwd [String] where it starts (the call's cwd:, else the session's)
|
|
24
32
|
# @param home [String] for ~, $HOME, ${HOME} and a bare cd
|
|
33
|
+
# @param env [Hash{String=>String}] the call's env: (task_create's):
|
|
34
|
+
# its GIT_DIR / GIT_WORK_TREE hold for each git unless it sets its own
|
|
25
35
|
# @return [Array<String, Symbol>] absolute dirs and :unknown, unique
|
|
26
|
-
def self.for(command, cwd:, home: Dir.home)
|
|
27
|
-
Walk.new(cwd, home).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
|
|
36
|
+
def self.for(command, cwd:, home: Dir.home, env: {})
|
|
37
|
+
Walk.new(cwd, home, env).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
|
|
28
38
|
rescue ArgumentError, EncodingError
|
|
29
39
|
[] # a NUL byte, invalid UTF-8: the shell would refuse it too
|
|
30
40
|
end
|
|
@@ -32,9 +42,13 @@ module Samagotchi
|
|
|
32
42
|
# Follows the current dir through the simple commands and records
|
|
33
43
|
# where each mutating git runs.
|
|
34
44
|
class Walk
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
# More dirs than this after a cd: the dir is unknown.
|
|
46
|
+
MAX_DIRS = 8
|
|
47
|
+
|
|
48
|
+
def initialize(cwd, home, env = {})
|
|
49
|
+
@dirs = [cwd]
|
|
37
50
|
@home = home
|
|
51
|
+
@env = env.slice("GIT_DIR", "GIT_WORK_TREE")
|
|
38
52
|
@subshells = []
|
|
39
53
|
@pushed = []
|
|
40
54
|
@found = []
|
|
@@ -43,8 +57,8 @@ module Samagotchi
|
|
|
43
57
|
def run(commands)
|
|
44
58
|
commands.each do |words|
|
|
45
59
|
case words
|
|
46
|
-
when :open then @subshells << @
|
|
47
|
-
when :close then @
|
|
60
|
+
when :open then @subshells << @dirs
|
|
61
|
+
when :close then @dirs = @subshells.pop || @dirs
|
|
48
62
|
else simple(words.dup)
|
|
49
63
|
end
|
|
50
64
|
end
|
|
@@ -54,7 +68,7 @@ module Samagotchi
|
|
|
54
68
|
private
|
|
55
69
|
|
|
56
70
|
def simple(words)
|
|
57
|
-
env =
|
|
71
|
+
env = @env.dup
|
|
58
72
|
while (word = words.first)
|
|
59
73
|
if word.match?(/\A[A-Za-z_]\w*=/)
|
|
60
74
|
key, value = word.split("=", 2)
|
|
@@ -69,46 +83,63 @@ module Samagotchi
|
|
|
69
83
|
case verb
|
|
70
84
|
when "cd" then cd(args)
|
|
71
85
|
when "pushd"
|
|
72
|
-
@pushed << @
|
|
73
|
-
@
|
|
74
|
-
when "popd" then @
|
|
86
|
+
@pushed << @dirs
|
|
87
|
+
@dirs = args.first ? dirs_after(args.first) : [nil]
|
|
88
|
+
when "popd" then @dirs = @pushed.pop unless @pushed.empty?
|
|
75
89
|
else git(args, env) if verb == "git" || verb.end_with?("/git")
|
|
76
90
|
end
|
|
77
91
|
end
|
|
78
92
|
|
|
79
93
|
def cd(args)
|
|
80
94
|
target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
|
|
81
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
@dirs = if target.nil? then [@home]
|
|
96
|
+
elsif target == "-" then [nil]
|
|
97
|
+
else dirs_after(target)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Every dir a cd to +target+ may land at from each current one: its
|
|
102
|
+
# lexical path and its physical one (nil for one it can't tell);
|
|
103
|
+
# [nil] past MAX_DIRS.
|
|
104
|
+
def dirs_after(target)
|
|
105
|
+
dirs = @dirs.flat_map { |dir| [false, true].map { |physical| expand(target, dir, physical: physical) } }.uniq
|
|
106
|
+
dirs.size > MAX_DIRS ? [nil] : dirs
|
|
85
107
|
end
|
|
86
108
|
|
|
87
109
|
def git(args, env)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
@found.concat(@dirs.product([false, true]).filter_map do |dir, physical|
|
|
111
|
+
where_git(args.dup, env, dir, physical)
|
|
112
|
+
end)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Where a mutating git run from +dir+ runs (a dir or :unknown), nil
|
|
116
|
+
# for another git; +physical+: its dir options' `..` taken the
|
|
117
|
+
# kernel's way.
|
|
118
|
+
def where_git(args, env, dir, physical)
|
|
119
|
+
expand = ->(arg, base) { expand(arg, base, physical: physical) }
|
|
120
|
+
work_tree = env["GIT_WORK_TREE"] && expand.call(env["GIT_WORK_TREE"], dir)
|
|
121
|
+
git_dir = env["GIT_DIR"] && expand.call(env["GIT_DIR"], dir)
|
|
91
122
|
sub = nil
|
|
92
123
|
until args.empty?
|
|
93
124
|
arg = args.shift
|
|
94
125
|
case arg
|
|
95
126
|
when *VALUE_OPTIONS
|
|
96
127
|
value = args.shift.to_s
|
|
97
|
-
dir = expand(value, dir) if arg == "-C"
|
|
98
|
-
git_dir = expand(value, dir) if arg == "--git-dir"
|
|
99
|
-
work_tree = expand(value, dir) if arg == "--work-tree"
|
|
100
|
-
when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
|
|
101
|
-
when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
|
|
128
|
+
dir = expand.call(value, dir) if arg == "-C"
|
|
129
|
+
git_dir = expand.call(value, dir) if arg == "--git-dir"
|
|
130
|
+
work_tree = expand.call(value, dir) if arg == "--work-tree"
|
|
131
|
+
when /\A--git-dir=(.*)\z/m then git_dir = expand.call(Regexp.last_match(1), dir)
|
|
132
|
+
when /\A--work-tree=(.*)\z/m then work_tree = expand.call(Regexp.last_match(1), dir)
|
|
102
133
|
when /\A-/ then next
|
|
103
134
|
else
|
|
104
135
|
sub = arg
|
|
105
136
|
break
|
|
106
137
|
end
|
|
107
138
|
end
|
|
108
|
-
return unless MUTATING.include?(sub)
|
|
109
|
-
return if sub == "stash" && STASH_READS.include?(args.first)
|
|
139
|
+
return nil unless MUTATING.include?(sub)
|
|
140
|
+
return nil if sub == "stash" && STASH_READS.include?(args.first)
|
|
110
141
|
|
|
111
|
-
|
|
142
|
+
work_tree || repo_of(git_dir) || dir || :unknown
|
|
112
143
|
end
|
|
113
144
|
|
|
114
145
|
def repo_of(git_dir)
|
|
@@ -117,17 +148,18 @@ module Samagotchi
|
|
|
117
148
|
File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
|
|
118
149
|
end
|
|
119
150
|
|
|
120
|
-
# An absolute path for +arg+ against +dir
|
|
121
|
-
#
|
|
151
|
+
# An absolute path for +arg+ against +dir+ (`..` lexical, or
|
|
152
|
+
# +physical+: ProtectedPaths.expand); nil when it can't
|
|
153
|
+
# be told from the text (a variable, a substitution, a relative path
|
|
122
154
|
# against an unknown dir).
|
|
123
|
-
def expand(arg, dir)
|
|
155
|
+
def expand(arg, dir, physical: false)
|
|
124
156
|
return nil if arg.include?(ShellLex::SUBST)
|
|
125
157
|
|
|
126
158
|
arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
|
|
127
159
|
return nil if arg.include?("$") || arg.start_with?("~")
|
|
128
|
-
return
|
|
160
|
+
return nil unless arg.start_with?("/") || dir
|
|
129
161
|
|
|
130
|
-
dir
|
|
162
|
+
physical ? ProtectedPaths.expand(arg, dir) : File.expand_path(arg, dir)
|
|
131
163
|
end
|
|
132
164
|
end
|
|
133
165
|
end
|
|
@@ -114,7 +114,7 @@ module Samagotchi
|
|
|
114
114
|
end
|
|
115
115
|
redirected ||= exec_redirect?(tokens, indices, words)
|
|
116
116
|
end
|
|
117
|
-
cuts.sort_by { |span, _| -span.begin }.each_with_object(
|
|
117
|
+
cuts.sort_by { |span, _| -span.begin }.each_with_object(text.dup) do |(span, replacement), out|
|
|
118
118
|
out[span] = replacement
|
|
119
119
|
end
|
|
120
120
|
rescue ArgumentError, EncodingError
|