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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Guardrails
|
|
7
|
+
# The guardrails' path primitives: how a path is expanded, resolved and
|
|
8
|
+
# compared, shared by every surface that judges one (ProtectedPaths,
|
|
9
|
+
# Outside, ShellPaths, RmTargets, ReviewPaths, PairCommand, Targets,
|
|
10
|
+
# ParentApprovals, System 1's SelfProtect). Each surface keeps its own
|
|
11
|
+
# predicate; this only says where a path lands and whether it is in a
|
|
12
|
+
# dir. docs/internals/tool-guardrails.md maps which surface uses which.
|
|
13
|
+
module PathResolve
|
|
14
|
+
# How many symlinks .real follows on a path that doesn't exist
|
|
15
|
+
# (about the kernel's own ELOOP limit).
|
|
16
|
+
MAX_LINKS = 40
|
|
17
|
+
|
|
18
|
+
# $HOME, ${HOME} and the XDG dirs at a word's start (.expand_word).
|
|
19
|
+
EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
|
|
20
|
+
# Where XDG dirs default when unset.
|
|
21
|
+
XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
|
|
22
|
+
"XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
|
|
23
|
+
|
|
24
|
+
# The tmp dirs besides Dir.tmpdir (.tmp_roots).
|
|
25
|
+
TMP_DIRS = %w[/tmp /var/tmp].freeze
|
|
26
|
+
|
|
27
|
+
# A dir's disk answer (.folds_case?), asked once per dir.
|
|
28
|
+
FOLDS = {}
|
|
29
|
+
FOLDS_MUTEX = Mutex.new
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# +path+ as an absolute path the way the kernel opens it: ~ expanded,
|
|
34
|
+
# relative to +base+, and each `..` taken from where the dir before it
|
|
35
|
+
# really is (a `..` after a symlinked dir climbs out of its target,
|
|
36
|
+
# not back to the link's parent as File.expand_path has it). It
|
|
37
|
+
# differs from File.expand_path only for such a climb; the parts
|
|
38
|
+
# after the last `..` are kept as given (symlinks not resolved).
|
|
39
|
+
# A shell's cd may land at either (ShellPaths::Walk keeps both); a file
|
|
40
|
+
# argument and a tool's path go through this.
|
|
41
|
+
# @param path [String]
|
|
42
|
+
# @param base [String, nil] for a relative path (Dir.pwd when nil)
|
|
43
|
+
def expand(path, base = nil)
|
|
44
|
+
path = path.to_s
|
|
45
|
+
head, sep, tail = path.partition("/")
|
|
46
|
+
path = "#{File.expand_path(head)}#{sep}#{tail}" if head.start_with?("~")
|
|
47
|
+
path = File.join(File.expand_path(base || Dir.pwd), path) unless path.start_with?("/")
|
|
48
|
+
parts = []
|
|
49
|
+
path.split("/").each do |part|
|
|
50
|
+
next if part.empty? || part == "."
|
|
51
|
+
|
|
52
|
+
part == ".." ? parts = climb(parts) : parts << part
|
|
53
|
+
end
|
|
54
|
+
"/#{parts.join("/")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The parts of +parts+'s parent dir: the lexical one unless the dir
|
|
58
|
+
# is (or goes through) a symlink whose target's parent is elsewhere.
|
|
59
|
+
def climb(parts)
|
|
60
|
+
lexical = parts[0...-1]
|
|
61
|
+
dir = "/#{parts.join("/")}"
|
|
62
|
+
return lexical unless parts.any? && File.directory?(dir)
|
|
63
|
+
|
|
64
|
+
physical = File.dirname(File.realpath(dir))
|
|
65
|
+
return lexical if File.realpath("/#{lexical.join("/")}") == physical
|
|
66
|
+
|
|
67
|
+
physical.split("/").reject(&:empty?)
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
lexical
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# +path+ (.expand) with symlinks resolved on its longest existing
|
|
73
|
+
# parent: where the kernel lands for it. A part that is a dangling
|
|
74
|
+
# symlink is followed to its target (a write there creates the
|
|
75
|
+
# target), up to MAX_LINKS times; the missing tail is kept as given.
|
|
76
|
+
def real(path)
|
|
77
|
+
path = expand(path)
|
|
78
|
+
MAX_LINKS.times do
|
|
79
|
+
resolved = existing_prefix_real(path)
|
|
80
|
+
return resolved unless resolved.is_a?(Array)
|
|
81
|
+
|
|
82
|
+
path = resolved.first
|
|
83
|
+
end
|
|
84
|
+
path
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The old, lexical answer: File.expand_path (lexical `..`), then
|
|
90
|
+
# realpath of the longest existing prefix, stopping at a dangling
|
|
91
|
+
# symlink. .reals keeps it beside .real.
|
|
92
|
+
def lexical_real(path)
|
|
93
|
+
path = File.expand_path(path)
|
|
94
|
+
rest = []
|
|
95
|
+
current = path
|
|
96
|
+
until File.exist?(current) || current == File.dirname(current)
|
|
97
|
+
rest.unshift(File.basename(current))
|
|
98
|
+
current = File.dirname(current)
|
|
99
|
+
end
|
|
100
|
+
File.join(File.realpath(current), *rest)
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
path
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Every place +path+ may be judged as: .lexical_real (what these
|
|
106
|
+
# checks always looked at) and .real (where the kernel lands), one
|
|
107
|
+
# entry when they agree. A check that holds for any of them (a
|
|
108
|
+
# protected dir, a secret) or refuses unless all of them are inside
|
|
109
|
+
# (the project, tmp) refuses at least what it did with the lexical
|
|
110
|
+
# answer alone.
|
|
111
|
+
# @return [Array<String>]
|
|
112
|
+
def reals(path) = [lexical_real(path), real(path)].uniq
|
|
113
|
+
|
|
114
|
+
# realpath of +path+'s longest existing prefix with the rest
|
|
115
|
+
# appended (a String), or [the path with the first dangling symlink
|
|
116
|
+
# on the way replaced by its target] to resolve again.
|
|
117
|
+
def existing_prefix_real(path)
|
|
118
|
+
rest = []
|
|
119
|
+
current = path
|
|
120
|
+
until File.exist?(current) || current == File.dirname(current)
|
|
121
|
+
if File.symlink?(current)
|
|
122
|
+
target = expand(File.readlink(current), File.dirname(current))
|
|
123
|
+
return [expand(File.join(target, *rest))]
|
|
124
|
+
end
|
|
125
|
+
rest.unshift(File.basename(current))
|
|
126
|
+
current = File.dirname(current)
|
|
127
|
+
end
|
|
128
|
+
File.join(File.realpath(current), *rest)
|
|
129
|
+
end
|
|
130
|
+
private_class_method :climb, :existing_prefix_real
|
|
131
|
+
|
|
132
|
+
# Whether +path+ (resolved) is +root+ or in it, +root+ resolved
|
|
133
|
+
# either way (.reals).
|
|
134
|
+
def within?(path, root)
|
|
135
|
+
return false if root.nil? || root.to_s.empty?
|
|
136
|
+
|
|
137
|
+
reals(root.to_s.chomp("/")).any? { |dir| under?(path, dir) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whether +path+ is +dir+ or in it (both absolute, resolved): by their
|
|
141
|
+
# text, or on a disk that folds case (macOS) by their text cased alike.
|
|
142
|
+
# realpath gives an existing part its on-disk case, but a missing
|
|
143
|
+
# tail keeps the case it was typed in (<state>/CONTEXT/x before
|
|
144
|
+
# <state>/context exists), and the kernel will create it in the
|
|
145
|
+
# folder that later answers to either. With +fold+ false, by their
|
|
146
|
+
# text alone.
|
|
147
|
+
def under?(path, dir, fold: true)
|
|
148
|
+
return true if path == dir || path.start_with?(File.join(dir, ""))
|
|
149
|
+
return false unless fold
|
|
150
|
+
|
|
151
|
+
low = path.downcase
|
|
152
|
+
low_dir = dir.downcase
|
|
153
|
+
(low == low_dir || low.start_with?(File.join(low_dir, ""))) && folds_case?(dir)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether +path+ is in +dir+, not +dir+ itself (.under?'s terms).
|
|
157
|
+
def strictly_under?(path, dir, fold: true)
|
|
158
|
+
return true if path.start_with?(File.join(dir, ""))
|
|
159
|
+
return false unless fold
|
|
160
|
+
|
|
161
|
+
path.downcase.start_with?(File.join(dir.downcase, "")) && folds_case?(dir)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The tmp dirs that exist (Dir.tmpdir, /tmp, /var/tmp), resolved; read
|
|
165
|
+
# each time (specs and a call's env: change TMPDIR). With +session+
|
|
166
|
+
# (a resolved session root) only those it isn't in, by their text
|
|
167
|
+
# (exact case): a session rooted in tmp gets no tmp dir of its own.
|
|
168
|
+
# @param roots [Array<String>, nil] the tmp dirs to filter (these by
|
|
169
|
+
# default)
|
|
170
|
+
def tmp_roots(session: nil, roots: nil)
|
|
171
|
+
roots ||= [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
|
|
172
|
+
File.realpath(dir) if File.directory?(dir)
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
nil
|
|
175
|
+
end.uniq
|
|
176
|
+
session ? roots.reject { |tmp| under?(session, tmp, fold: false) } : roots
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A shell word with a leading ~, $HOME/${HOME}, an XDG dir or $TMPDIR
|
|
180
|
+
# expanded (+env+'s value, an unset XDG dir its default under +home+);
|
|
181
|
+
# nil when a $ or ~ is left. ShellPaths::Walk (cd and file words) and
|
|
182
|
+
# ShellGitDirs (git -C and the like) expand this way.
|
|
183
|
+
def expand_word(word, home:, env:)
|
|
184
|
+
word = expand_head(word, home: home, env: env)
|
|
185
|
+
return nil if word.include?("$") || word.start_with?("~")
|
|
186
|
+
|
|
187
|
+
word
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# +word+ with its leading ~, $HOME/${HOME}, XDG dir or $TMPDIR
|
|
191
|
+
# expanded as .expand_word does, the rest kept as it is ($ and globs
|
|
192
|
+
# included); an unset variable without a default leaves a "$".
|
|
193
|
+
def expand_head(word, home:, env:)
|
|
194
|
+
word = word.sub(%r{\A~(?=/|\z)}) { home }
|
|
195
|
+
word.sub(EXPANSION) { variable(Regexp.last_match(1) || Regexp.last_match(2), home, env) || "$" }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def variable(name, home, env)
|
|
199
|
+
return home if name == "HOME"
|
|
200
|
+
|
|
201
|
+
set = env[name].to_s
|
|
202
|
+
return set unless set.empty?
|
|
203
|
+
|
|
204
|
+
XDG_DEFAULTS[name] && File.join(home, XDG_DEFAULTS[name])
|
|
205
|
+
end
|
|
206
|
+
private_class_method :variable
|
|
207
|
+
|
|
208
|
+
# Whether the disk +path+'s deepest existing dir is on folds case: that
|
|
209
|
+
# dir (or the first one above it with a letter in its name) answers to
|
|
210
|
+
# its name with the case swapped. Asked once per dir. A relative or
|
|
211
|
+
# blank path has no disk to ask: false.
|
|
212
|
+
def folds_case?(path)
|
|
213
|
+
return false unless path.to_s.start_with?("/")
|
|
214
|
+
|
|
215
|
+
probe = path
|
|
216
|
+
probe = File.dirname(probe) until probe == "/" || File.directory?(probe)
|
|
217
|
+
probe = File.dirname(probe) until probe == "/" || File.basename(probe).swapcase != File.basename(probe)
|
|
218
|
+
return false if probe == "/"
|
|
219
|
+
|
|
220
|
+
FOLDS_MUTEX.synchronize do
|
|
221
|
+
FOLDS.fetch(probe) do
|
|
222
|
+
other = File.join(File.dirname(probe), File.basename(probe).swapcase)
|
|
223
|
+
FOLDS[probe] = File.exist?(other) && File.identical?(probe, other)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
rescue SystemCallError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "path_resolve"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Guardrails
|
|
5
7
|
# Core checks on what the file tools (write, edit, memory_write) touch,
|
|
6
8
|
# after the hooks and before the rules. Paths are compared with
|
|
7
|
-
# symlinks resolved (on the longest existing parent
|
|
9
|
+
# symlinks resolved (on the longest existing parent, `..` taken
|
|
10
|
+
# physically, a dangling symlink followed: PathResolve.reals, the
|
|
11
|
+
# lexical answer and where the write lands, a vote when either is
|
|
12
|
+
# protected).
|
|
8
13
|
# deny: the approval store's dir, installed bundles (.bundles/:
|
|
9
14
|
# hooks, rules, manifests) and attached context (a source's
|
|
10
15
|
# command runs later, outside the gate; chi context writes
|
|
@@ -41,40 +46,29 @@ module Samagotchi
|
|
|
41
46
|
targets = verdict.targets
|
|
42
47
|
return verdict unless targets && FILE_TOOLS.include?(targets.tool)
|
|
43
48
|
|
|
44
|
-
paths = targets.paths.
|
|
49
|
+
paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
|
|
45
50
|
@deny.each do |rule, root, reason|
|
|
46
|
-
next unless paths.any? { |p|
|
|
51
|
+
next unless paths.any? { |p| PathResolve.within?(p, root) }
|
|
47
52
|
|
|
48
53
|
verdict.deny!(reason, rule: rule, source: SOURCE, decided_by: "core")
|
|
49
54
|
end
|
|
50
55
|
@ask.each do |rule, root, reason|
|
|
51
|
-
next unless paths.any? { |p|
|
|
56
|
+
next unless paths.any? { |p| PathResolve.within?(p, root) }
|
|
52
57
|
|
|
53
58
|
verdict.ask!(reason, scopes: %w[once session], rule: rule, source: SOURCE, decided_by: "core")
|
|
54
59
|
end
|
|
55
60
|
verdict
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
File.join(File.realpath(current), *rest)
|
|
68
|
-
rescue SystemCallError
|
|
69
|
-
path
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def self.within?(path, root)
|
|
73
|
-
return false if root.nil? || root.to_s.empty?
|
|
74
|
-
|
|
75
|
-
root = real(root.to_s.chomp("/"))
|
|
76
|
-
path == root || path.start_with?(File.join(root, ""))
|
|
77
|
-
end
|
|
63
|
+
# The path primitives (PathResolve) as this class has long named
|
|
64
|
+
# them; specs and older callers reach them here.
|
|
65
|
+
def self.expand(path, base = nil) = PathResolve.expand(path, base)
|
|
66
|
+
def self.real(path) = PathResolve.real(path)
|
|
67
|
+
def self.lexical_real(path) = PathResolve.lexical_real(path)
|
|
68
|
+
def self.reals(path) = PathResolve.reals(path)
|
|
69
|
+
def self.within?(path, root) = PathResolve.within?(path, root)
|
|
70
|
+
def self.under?(path, dir) = PathResolve.under?(path, dir)
|
|
71
|
+
def self.folds_case?(path) = PathResolve.folds_case?(path)
|
|
78
72
|
end
|
|
79
73
|
end
|
|
80
74
|
end
|
|
@@ -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
|
|
@@ -272,6 +403,58 @@ module Samagotchi
|
|
|
272
403
|
names.zero? || options.any? { |a| GIT_BRANCH_LISTING.any? { |listing| a == listing || a.start_with?("#{listing}=") } }
|
|
273
404
|
end
|
|
274
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
|
+
|
|
275
458
|
CHECKS = {
|
|
276
459
|
"rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
|
|
277
460
|
"sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "chi_dirs"
|
|
4
|
+
require_relative "path_resolve"
|
|
4
5
|
require_relative "shell_paths"
|
|
5
6
|
require_relative "../paths"
|
|
6
7
|
|
|
@@ -10,9 +11,11 @@ module Samagotchi
|
|
|
10
11
|
# under the session's project root that isn't on the read denylist
|
|
11
12
|
# (chi's state and config dirs, home secret dirs, project secret files),
|
|
12
13
|
# checked on the path as given and as it resolves (a symlink in the
|
|
13
|
-
# project to ~/.ssh is ~/.ssh
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
|
|
15
|
+
# of its target, as the kernel opens it: PathResolve.reals).
|
|
16
|
+
# ReviewBoundary asks it for every read of a participant's turn; a
|
|
17
|
+
# review session's subjects (chi review, the participant page's Plan)
|
|
18
|
+
# go through the same check.
|
|
16
19
|
class ReviewPaths
|
|
17
20
|
# Home folders that hold keys and tokens, never read in a review turn
|
|
18
21
|
# (nor read or written in a pair turn).
|
|
@@ -43,8 +46,20 @@ module Samagotchi
|
|
|
43
46
|
SECRET = :secret
|
|
44
47
|
HOME_ROOT = :home_root
|
|
45
48
|
|
|
46
|
-
# chi's state dir
|
|
47
|
-
|
|
49
|
+
# chi's state dir, config dir and a hooks dir configured outside it
|
|
50
|
+
# (ChiDirs.review).
|
|
51
|
+
def self.chi_dirs = ChiDirs.review
|
|
52
|
+
|
|
53
|
+
# Whether +root+ is the home folder or a folder above it (symlinks
|
|
54
|
+
# resolved on both): no project to keep a participant's turn in. A
|
|
55
|
+
# review turn reads nothing there (#refusal: HOME_ROOT); a pair turn
|
|
56
|
+
# asks the owner before every write (PairBoundary).
|
|
57
|
+
# @param root [String] @param home [String, nil]
|
|
58
|
+
def self.home_root?(root, home)
|
|
59
|
+
return false if home.to_s.empty? || root.to_s.empty?
|
|
60
|
+
|
|
61
|
+
PathResolve.under?(PathResolve.real(home.to_s), PathResolve.real(root.to_s))
|
|
62
|
+
end
|
|
48
63
|
|
|
49
64
|
# @param root [String, nil] the session's project root (the work tree
|
|
50
65
|
# top of its folder); nil or blank refuses every path
|
|
@@ -66,14 +81,15 @@ module Samagotchi
|
|
|
66
81
|
def refusal(paths)
|
|
67
82
|
return OUTSIDE if paths.empty? || @root.empty?
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
|
|
85
|
+
|
|
86
|
+
real_root = PathResolve.real(@root)
|
|
71
87
|
|
|
72
88
|
paths.each do |path|
|
|
73
89
|
given = File.expand_path(path)
|
|
74
|
-
|
|
75
|
-
return OUTSIDE unless
|
|
76
|
-
return SECRET if secret?(given) || secret?(real)
|
|
90
|
+
reals = PathResolve.reals(path)
|
|
91
|
+
return OUTSIDE unless reals.all? { |real| PathResolve.under?(real, real_root) }
|
|
92
|
+
return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
|
|
77
93
|
end
|
|
78
94
|
nil
|
|
79
95
|
end
|
|
@@ -86,7 +102,7 @@ module Samagotchi
|
|
|
86
102
|
def secret_refusal(paths)
|
|
87
103
|
paths.each do |path|
|
|
88
104
|
given = File.expand_path(path)
|
|
89
|
-
return SECRET if secret?(given) || secret?(
|
|
105
|
+
return SECRET if secret?(given) || PathResolve.reals(path).any? { |real| secret?(real) }
|
|
90
106
|
end
|
|
91
107
|
nil
|
|
92
108
|
end
|
|
@@ -99,13 +115,13 @@ module Samagotchi
|
|
|
99
115
|
@denied_roots ||= begin
|
|
100
116
|
roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
|
|
101
117
|
roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
|
|
102
|
-
(roots + roots.map { |root|
|
|
118
|
+
(roots + roots.map { |root| PathResolve.real(root) }).uniq
|
|
103
119
|
end
|
|
104
120
|
end
|
|
105
121
|
|
|
106
122
|
# Whether +path+ (absolute) is on the read denylist.
|
|
107
123
|
def secret?(path)
|
|
108
|
-
return true if denied_roots.any? { |root|
|
|
124
|
+
return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
|
|
109
125
|
|
|
110
126
|
parts = path.split(File::SEPARATOR)
|
|
111
127
|
return true if parts.any? { |part| SECRET_FILE.match?(part) }
|