samagotchi 0.129.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 +45 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +18 -9
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +60 -16
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +13 -4
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/sessions.md +1 -1
- 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/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
- data/lib/samagotchi/guardrails/outside.rb +22 -20
- data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
- data/lib/samagotchi/guardrails/pair_command.rb +70 -10
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
- data/lib/samagotchi/guardrails/review_paths.rb +13 -11
- data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
- data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
- data/lib/samagotchi/guardrails/targets.rb +11 -13
- 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/system_one/self_protect.rb +34 -19
- 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 +23 -18
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +9 -1
- metadata +3 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "outside"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "path_resolve"
|
|
5
5
|
require_relative "shell_lex"
|
|
6
6
|
require_relative "shell_paths"
|
|
7
7
|
require_relative "shared_git"
|
|
@@ -88,8 +88,8 @@ module Samagotchi
|
|
|
88
88
|
# repository (and not into root, which may sit inside the main one),
|
|
89
89
|
# under chi's env or the call's env: (Targets#shell_envs).
|
|
90
90
|
def names_other_checkout?(targets, root)
|
|
91
|
-
real_root =
|
|
92
|
-
others = [*@worktrees.call, @common_dir.call].compact.map { |dir|
|
|
91
|
+
real_root = PathResolve.real(root)
|
|
92
|
+
others = [*@worktrees.call, @common_dir.call].compact.map { |dir| PathResolve.real(dir) } - [real_root]
|
|
93
93
|
return false if others.empty?
|
|
94
94
|
|
|
95
95
|
commands = ShellLex.simple_commands(ShellLex.lex(targets.command.to_s))
|
|
@@ -99,8 +99,8 @@ module Samagotchi
|
|
|
99
99
|
walk.step(words).any? do |_word, path|
|
|
100
100
|
next false unless path
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
!
|
|
102
|
+
PathResolve.reals(path).any? do |real|
|
|
103
|
+
!PathResolve.under?(real, real_root) && others.any? { |dir| PathResolve.under?(real, dir) }
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
-
|
|
5
|
-
require_relative "protected_paths"
|
|
4
|
+
require_relative "path_resolve"
|
|
6
5
|
require_relative "../memory_bundle/index_sync"
|
|
7
6
|
|
|
8
7
|
module Samagotchi
|
|
@@ -20,17 +19,18 @@ module Samagotchi
|
|
|
20
19
|
# A path whose landing place resolving can't tell (.resolve: a dangling
|
|
21
20
|
# symlink, a file with another hard link) is always outside.
|
|
22
21
|
module Outside
|
|
23
|
-
TMP_DIRS =
|
|
22
|
+
TMP_DIRS = PathResolve::TMP_DIRS
|
|
24
23
|
|
|
25
24
|
# @param path [String] absolute
|
|
26
25
|
# @param root [String] the session's repo root (or cwd)
|
|
27
26
|
def self.outside?(path, root:)
|
|
28
27
|
reals = resolve(path) or return true
|
|
29
|
-
session =
|
|
28
|
+
session = PathResolve.real(root)
|
|
29
|
+
tmp = nil
|
|
30
30
|
reals.any? do |real|
|
|
31
31
|
next false if within?(real, session)
|
|
32
32
|
next false if MemoryBundle::IndexSync.memory_scope(path)
|
|
33
|
-
next false if
|
|
33
|
+
next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
|
|
34
34
|
|
|
35
35
|
true
|
|
36
36
|
end
|
|
@@ -44,7 +44,7 @@ module Samagotchi
|
|
|
44
44
|
# @param root [String] the session's project root
|
|
45
45
|
def self.inside?(path, root:)
|
|
46
46
|
reals = resolve(path) or return false
|
|
47
|
-
session =
|
|
47
|
+
session = PathResolve.real(root)
|
|
48
48
|
reals.all? { |real| within?(real, session) }
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -55,15 +55,16 @@ module Samagotchi
|
|
|
55
55
|
# @param root [String] the session's repo root (or cwd)
|
|
56
56
|
def self.tmp_write?(path, root:)
|
|
57
57
|
reals = resolve(path) or return false
|
|
58
|
-
session =
|
|
59
|
-
|
|
58
|
+
session = PathResolve.real(root)
|
|
59
|
+
tmp = session_tmp_roots(session)
|
|
60
|
+
reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
# +path+ resolved (
|
|
63
|
+
# +path+ resolved (PathResolve.reals: the lexical answer and where
|
|
63
64
|
# the kernel lands, a `..` after a symlinked dir climbing out of its
|
|
64
65
|
# target), or nil when that doesn't say where a write lands: the path
|
|
65
66
|
# is or goes through a dangling symlink (a write would create its
|
|
66
|
-
# target;
|
|
67
|
+
# target; PathResolve.real follows it, the lexical answer stops at
|
|
67
68
|
# it, and either way the link's own place doesn't count as where the
|
|
68
69
|
# file is), or is a file with another hard link (a write would change
|
|
69
70
|
# that file too). Both outside? and tmp_write? read it, so neither
|
|
@@ -71,7 +72,7 @@ module Samagotchi
|
|
|
71
72
|
# @param path [String] absolute
|
|
72
73
|
# @return [Array<String>, nil]
|
|
73
74
|
def self.resolve(path)
|
|
74
|
-
reals =
|
|
75
|
+
reals = PathResolve.reals(path)
|
|
75
76
|
return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
|
|
76
77
|
return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
|
|
77
78
|
|
|
@@ -80,16 +81,17 @@ module Samagotchi
|
|
|
80
81
|
nil
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
# The tmp dirs that exist, resolved.
|
|
84
|
-
def self.tmp_roots
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
nil
|
|
89
|
-
end.uniq
|
|
90
|
-
end
|
|
84
|
+
# The tmp dirs that exist, resolved (PathResolve.tmp_roots).
|
|
85
|
+
def self.tmp_roots = PathResolve.tmp_roots
|
|
86
|
+
|
|
87
|
+
# The tmp dirs +session+ (resolved) isn't in (PathResolve.tmp_roots).
|
|
88
|
+
def self.session_tmp_roots(session) = PathResolve.tmp_roots(session: session, roots: tmp_roots)
|
|
91
89
|
|
|
92
|
-
|
|
90
|
+
# Whether +path+ is +root+ or in it (PathResolve.under?): cased alike
|
|
91
|
+
# on a disk that folds case when +root+ exists (resolved, it has its
|
|
92
|
+
# on-disk case), by their exact text when it doesn't (a missing root
|
|
93
|
+
# keeps the case it was typed in).
|
|
94
|
+
def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
end
|
|
@@ -25,7 +25,9 @@ module Samagotchi
|
|
|
25
25
|
# read or written (read, write, edit, a plugin's targets: paths), and
|
|
26
26
|
# on what a command does (execute, and any call whose targets carry
|
|
27
27
|
# a command: PairCommand, which also refuses chi and chi web on a
|
|
28
|
-
# loopback name,
|
|
28
|
+
# loopback name, URL openers (open, xdg-open, a browser) and
|
|
29
|
+
# listeners (nc -l, socat …LISTEN, a static HTTP server), background
|
|
30
|
+
# and scheduled jobs, the environment and
|
|
29
31
|
# credential printers, git settings that run commands, home sweeps
|
|
30
32
|
# and links to them, and a program named by a $var or a glob); a
|
|
31
33
|
# write or edit of git's hooks (GIT_HOOKS);
|
|
@@ -78,8 +80,17 @@ module Samagotchi
|
|
|
78
80
|
GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
|
|
79
81
|
"(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
|
|
80
82
|
GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
# chi web on loopback asks the owner's token too (Web::App#token_gate,
|
|
84
|
+
# LAN mode); this text check stays as defense in depth: a loopback-only
|
|
85
|
+
# chi web still answers loopback as the owner.
|
|
86
|
+
CHI_WEB = "a participant's turn can't call chi web on this machine: it is the owner's (sessions, shares, answers)"
|
|
87
|
+
# A page opened in the owner's browser carries the owner's cookies
|
|
88
|
+
# (chi web's on 127.0.0.1 or the LAN address among them) to whatever
|
|
89
|
+
# it points at; a port listening here could catch them.
|
|
90
|
+
OPENER = "a participant's turn can't open a URL or a file in the owner's apps: a browser sends that page the " \
|
|
91
|
+
"owner's cookies"
|
|
92
|
+
LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
|
|
93
|
+
"owner's browser cookies"
|
|
83
94
|
VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
|
|
84
95
|
UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
|
|
85
96
|
"with the write tool"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "outside"
|
|
4
|
+
require_relative "path_resolve"
|
|
4
5
|
require_relative "read_only_shell"
|
|
5
6
|
require_relative "review_paths"
|
|
6
7
|
require_relative "shell_paths"
|
|
@@ -11,7 +12,8 @@ module Samagotchi
|
|
|
11
12
|
# One shell command of a participant's pair turn, read the way
|
|
12
13
|
# PairBoundary needs it (the refusal words are PairBoundary's):
|
|
13
14
|
# - #refusal: why it may not run, nil when it may: chi, something left
|
|
14
|
-
# running,
|
|
15
|
+
# running, a URL opener or a listener (OPENERS, LISTENERS: the owner's
|
|
16
|
+
# browser cookies), the environment or the keychain, git settings that run
|
|
15
17
|
# commands, a folder sweep, a secret (a path a word resolves to, a glob
|
|
16
18
|
# or a $var whose fixed part leads into one, SECRET_TEXT for the
|
|
17
19
|
# rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
|
|
@@ -66,6 +68,24 @@ module Samagotchi
|
|
|
66
68
|
# A credential helper run by itself (docker-credential-osxkeychain get).
|
|
67
69
|
CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
|
|
68
70
|
ENV_TEXT = %r{/proc/[^\s]*/environ}
|
|
71
|
+
# Programs that open a URL or a file in the owner's apps (a browser
|
|
72
|
+
# sends the owner's cookies with it), whatever their arguments; gio
|
|
73
|
+
# with its open verb.
|
|
74
|
+
OPENERS = %w[open xdg-open gnome-open kde-open kde-open5 kfmclient exo-open wslview sensible-browser
|
|
75
|
+
x-www-browser www-browser google-chrome google-chrome-stable chromium chromium-browser
|
|
76
|
+
firefox safari msedge microsoft-edge brave-browser].freeze
|
|
77
|
+
# Programs that listen on a port whatever their arguments (static
|
|
78
|
+
# HTTP servers), and the netcats, which listen with -l.
|
|
79
|
+
LISTENERS = %w[http-server live-server httpd lighttpd nginx caddy miniserve darkhttpd webfsd].freeze
|
|
80
|
+
NETCATS = %w[nc ncat netcat].freeze
|
|
81
|
+
NC_LISTEN = /\A-[a-zA-Z0-9]*l|\A--listen\z/
|
|
82
|
+
# An interpreter's flags or arguments that serve a port (python -m
|
|
83
|
+
# http.server, ruby -run -e httpd, php -S, busybox httpd).
|
|
84
|
+
SERVE_MODULES = %w[http.server SimpleHTTPServer CGIHTTPServer socketserver].freeze
|
|
85
|
+
# An interpreter's script that opens a URL in the owner's apps, or
|
|
86
|
+
# listens on a port.
|
|
87
|
+
SCRIPT_OPENER_TEXT = /\bwebbrowser\b|\bopen location\b|openExternal|xdg-open|\bopen\s+["']?https?:/
|
|
88
|
+
SCRIPT_LISTEN_TEXT = /\blisten\s*\(|\bhttp\.server\b|\bserve_forever\b|HTTPServer|TCPServer|socketserver|createServer|WEBrick|\bhttpd\b/
|
|
69
89
|
# This machine's loopback names (.web_text): localhost and its
|
|
70
90
|
# subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
|
|
71
91
|
LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
|
|
@@ -89,6 +109,9 @@ module Samagotchi
|
|
|
89
109
|
GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
|
|
90
110
|
# Devices a command may name in the project's stead.
|
|
91
111
|
DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
|
|
112
|
+
# A word that starts with $TMPDIR or ${TMPDIR}.
|
|
113
|
+
TMPDIR_WORD = /\A\$(?:\{TMPDIR\}|TMPDIR\b)/
|
|
114
|
+
|
|
92
115
|
# A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
|
|
93
116
|
# or an absolute path, at the text's start or after a space, quote,
|
|
94
117
|
# bracket, = or comma.
|
|
@@ -120,7 +143,7 @@ module Samagotchi
|
|
|
120
143
|
# (ShellPaths::HOOKS_PART): git runs what is there.
|
|
121
144
|
def self.git_hook?(path)
|
|
122
145
|
path.to_s.match?(ShellPaths::HOOKS_PART) ||
|
|
123
|
-
(path.to_s.start_with?("/") &&
|
|
146
|
+
(path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
|
|
124
147
|
end
|
|
125
148
|
|
|
126
149
|
# A command's text naming chi web's endpoint on this machine: a
|
|
@@ -236,10 +259,34 @@ module Samagotchi
|
|
|
236
259
|
return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
|
|
237
260
|
return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
|
|
238
261
|
return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
|
|
262
|
+
return PairBoundary::OPENER if opener?(program, args)
|
|
263
|
+
return PairBoundary::LISTENER if listener?(program, args)
|
|
239
264
|
|
|
240
265
|
nil
|
|
241
266
|
end
|
|
242
267
|
|
|
268
|
+
# open, xdg-open, a browser, gio open; osascript's open location is
|
|
269
|
+
# its script's (#script_text_refusal).
|
|
270
|
+
def opener?(program, args)
|
|
271
|
+
OPENERS.include?(program.to_s.downcase) || (program == "gio" && args.first == "open")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# nc -l, socat with a …LISTEN address, a static HTTP server, an
|
|
275
|
+
# interpreter serving a module (python -m http.server), ruby's un
|
|
276
|
+
# httpd (ruby -run -e httpd, ruby being a wrapper word: the args are
|
|
277
|
+
# the whole command's), php -S, busybox httpd.
|
|
278
|
+
def listener?(program, args)
|
|
279
|
+
name = program.to_s
|
|
280
|
+
return true if LISTENERS.include?(name)
|
|
281
|
+
return true if NETCATS.include?(name) && args.any? { |arg| arg.match?(NC_LISTEN) }
|
|
282
|
+
return true if name == "socat" && args.any? { |arg| arg.match?(/LISTEN/i) }
|
|
283
|
+
return true if name.start_with?("php") && args.include?("-S")
|
|
284
|
+
return true if name == "busybox" && args.first == "httpd"
|
|
285
|
+
return true if args.each_cons(2).any? { |flag, value| flag == "-m" && SERVE_MODULES.include?(value) }
|
|
286
|
+
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
243
290
|
# A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
|
|
244
291
|
# ${e}i); the test brackets and braces are words of their own.
|
|
245
292
|
def unplain_program?(word)
|
|
@@ -297,6 +344,8 @@ module Samagotchi
|
|
|
297
344
|
return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
|
|
298
345
|
return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
|
|
299
346
|
return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
|
|
347
|
+
return PairBoundary::OPENER if script.match?(SCRIPT_OPENER_TEXT)
|
|
348
|
+
return PairBoundary::LISTENER if script.match?(SCRIPT_LISTEN_TEXT)
|
|
300
349
|
|
|
301
350
|
nil
|
|
302
351
|
end
|
|
@@ -413,12 +462,16 @@ module Samagotchi
|
|
|
413
462
|
|
|
414
463
|
# A word Walk can't resolve: a glob checked by what it matches and by
|
|
415
464
|
# its fixed part, a $var by its fixed part (the text before the first
|
|
416
|
-
# glob character or $, with
|
|
417
|
-
#
|
|
465
|
+
# glob character or $, with a leading ~, $HOME, XDG dir or $TMPDIR
|
|
466
|
+
# expanded: PathResolve.expand_head). After $TMPDIR the fixed part
|
|
467
|
+
# counts only inside a denied root, not as the start of one's name: an
|
|
468
|
+
# isolated run keeps its state dir in tmp, and `ls $TMPDIR/*` is no
|
|
469
|
+
# reach for it. A script body (spaces, quotes), a data heredoc or a
|
|
470
|
+
# $(…) is left to the other checks.
|
|
418
471
|
def unresolved_secret?(word)
|
|
419
472
|
return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
|
|
420
473
|
|
|
421
|
-
expanded =
|
|
474
|
+
expanded = PathResolve.expand_head(word, home: @home, env: ENV)
|
|
422
475
|
fixed = expanded[/\A[^*?\[\]{}$]*/]
|
|
423
476
|
dir = @cwd || Dir.pwd
|
|
424
477
|
if !expanded.include?("$") && expanded.match?(GLOB)
|
|
@@ -427,18 +480,22 @@ module Samagotchi
|
|
|
427
480
|
end
|
|
428
481
|
return false if fixed.empty?
|
|
429
482
|
|
|
430
|
-
|
|
483
|
+
prefix = File.expand_path(fixed, dir)
|
|
484
|
+
word.match?(TMPDIR_WORD) ? inside_denied?(prefix) : leads_into_denied?(prefix)
|
|
431
485
|
end
|
|
432
486
|
|
|
487
|
+
# Whether a path's text is a denied root or inside one.
|
|
488
|
+
def inside_denied?(path) = @paths.denied_roots.any? { |root| PathResolve.under?(path, root) }
|
|
489
|
+
|
|
433
490
|
# Whether a path's text is the start of a denied root's (its parent,
|
|
434
491
|
# or part of its name: ~/.ss) or inside one.
|
|
435
492
|
def leads_into_denied?(prefix)
|
|
436
|
-
@paths.denied_roots.any? { |root| root.start_with?(prefix) ||
|
|
493
|
+
@paths.denied_roots.any? { |root| root.start_with?(prefix) || PathResolve.under?(prefix, root) }
|
|
437
494
|
end
|
|
438
495
|
|
|
439
496
|
# Whether +path+ is a denied root's folder or one above it.
|
|
440
497
|
def above_denied?(path)
|
|
441
|
-
|
|
498
|
+
PathResolve.reals(path).any? { |real| @paths.denied_roots.any? { |root| PathResolve.under?(root, real) } }
|
|
442
499
|
end
|
|
443
500
|
|
|
444
501
|
def link_command?(words)
|
|
@@ -524,7 +581,7 @@ module Samagotchi
|
|
|
524
581
|
def text_inside?(walk, text)
|
|
525
582
|
return true unless text.match?(%r{\A(?:~|\.\.|/)})
|
|
526
583
|
|
|
527
|
-
expanded = text
|
|
584
|
+
expanded = expand_tilde(text)
|
|
528
585
|
return false if expanded.start_with?("~")
|
|
529
586
|
|
|
530
587
|
walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
|
|
@@ -549,12 +606,15 @@ module Samagotchi
|
|
|
549
606
|
if SHELLS.include?(program) || program == "eval"
|
|
550
607
|
!nested(script).outside_writes?
|
|
551
608
|
else
|
|
552
|
-
script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(path
|
|
609
|
+
script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(expand_tilde(path), @cwd || Dir.pwd)) }
|
|
553
610
|
end
|
|
554
611
|
end
|
|
555
612
|
end
|
|
556
613
|
|
|
557
614
|
def writable?(path) = PairCommand.writable?(path, root: @root, cwd: @cwd)
|
|
615
|
+
|
|
616
|
+
# +text+ with a leading ~ (alone or before a /) as the home folder.
|
|
617
|
+
def expand_tilde(text) = text.sub(%r{\A~(?=/|\z)}, @home)
|
|
558
618
|
end
|
|
559
619
|
end
|
|
560
620
|
end
|
|
@@ -6,6 +6,8 @@ require_relative "../memory_paths"
|
|
|
6
6
|
require_relative "../hooks/loader"
|
|
7
7
|
require_relative "approval"
|
|
8
8
|
require_relative "verdict"
|
|
9
|
+
require_relative "chi_dirs"
|
|
10
|
+
require_relative "path_resolve"
|
|
9
11
|
require_relative "protected_paths"
|
|
10
12
|
|
|
11
13
|
module Samagotchi
|
|
@@ -80,23 +82,18 @@ module Samagotchi
|
|
|
80
82
|
|
|
81
83
|
dirs = chi_dirs
|
|
82
84
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
83
|
-
reals = paths.flat_map { |path|
|
|
84
|
-
return true if reals.any? { |real| dirs.any? { |dir|
|
|
85
|
+
reals = paths.flat_map { |path| PathResolve.reals(path) }
|
|
86
|
+
return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
|
|
85
87
|
|
|
86
88
|
command = fetch(facts, :command).to_s
|
|
87
89
|
return false if command.empty?
|
|
88
90
|
|
|
89
|
-
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(
|
|
91
|
+
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(PathResolve.real(dir)) }
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
# chi's config dir, the hooks dir, the approval store's dir
|
|
93
|
-
# installed bundles' dir.
|
|
94
|
-
def chi_dirs
|
|
95
|
-
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
96
|
-
bundles = begin MemoryPaths.bundles_dir rescue nil end
|
|
97
|
-
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
|
|
98
|
-
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
99
|
-
end
|
|
94
|
+
# chi's config dir, the hooks dir, the approval store's dir, the
|
|
95
|
+
# installed bundles' dir and attached context (ChiDirs.protected).
|
|
96
|
+
def chi_dirs = ChiDirs.protected
|
|
100
97
|
|
|
101
98
|
# An approval: its kind says so, or it carries approval facts.
|
|
102
99
|
def approval?(pending)
|
|
@@ -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
|