samagotchi 0.128.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +3 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "outside"
|
|
4
|
+
require_relative "path_resolve"
|
|
5
|
+
require_relative "read_only_shell"
|
|
4
6
|
require_relative "review_paths"
|
|
5
7
|
require_relative "shell_paths"
|
|
6
8
|
require_relative "shell_lex"
|
|
@@ -10,17 +12,23 @@ module Samagotchi
|
|
|
10
12
|
# One shell command of a participant's pair turn, read the way
|
|
11
13
|
# PairBoundary needs it (the refusal words are PairBoundary's):
|
|
12
14
|
# - #refusal: why it may not run, nil when it may: chi, something left
|
|
13
|
-
# 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
|
|
14
17
|
# commands, a folder sweep, a secret (a path a word resolves to, a glob
|
|
15
18
|
# or a $var whose fixed part leads into one, SECRET_TEXT for the
|
|
16
19
|
# rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
|
|
17
20
|
# a command of its own, one it hands another interpreter (ruby -e,
|
|
18
21
|
# python <<EOF) by its text. A command it can't read is refused
|
|
19
22
|
# (UNREADABLE), never let through on a text match;
|
|
20
|
-
# - #outside_writes?: whether it
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
23
|
+
# - #outside_writes?: whether it may change something outside the
|
|
24
|
+
# project, failing closed: no unless it only reads
|
|
25
|
+
# (ReadOnlyShell.read_only?), or only reads but for writes into the
|
|
26
|
+
# project or a tmp dir (ReadOnlyShell.read_only_but_writes?), or
|
|
27
|
+
# stays inside the project (#stays_inside?: it runs in the project and
|
|
28
|
+
# every path it names, an option's value, a glob's fixed part and the
|
|
29
|
+
# paths in a script it hands on among them, lands in the project or a
|
|
30
|
+
# tmp dir, Outside.inside? / Outside.tmp_write?). A word it can't
|
|
31
|
+
# resolve ($var, $(…), a dir after cd $X) counts as outside.
|
|
24
32
|
# A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
|
|
25
33
|
# is not looked into: it's text being written, not run.
|
|
26
34
|
class PairCommand
|
|
@@ -37,45 +45,140 @@ module Samagotchi
|
|
|
37
45
|
INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
|
|
38
46
|
# Options whose next word is code or a command, for any program.
|
|
39
47
|
CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
|
|
40
|
-
# Programs that detach what they start.
|
|
41
|
-
DETACHERS = %w[nohup setsid disown daemonize].freeze
|
|
48
|
+
# Programs that detach what they start, or run it later (cron, at).
|
|
49
|
+
DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
|
|
42
50
|
# Programs that print the environment or a keychain's secrets, whatever
|
|
43
51
|
# their arguments.
|
|
44
|
-
ENV_READERS = %w[printenv security].freeze
|
|
52
|
+
ENV_READERS = %w[printenv security op].freeze
|
|
53
|
+
# Commands that print a stored credential (a token, a password, a
|
|
54
|
+
# key), by the program and the words that say so in its arguments.
|
|
55
|
+
CREDENTIAL_PRINTERS = {
|
|
56
|
+
"gh" => [%w[auth token]],
|
|
57
|
+
"git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
|
|
58
|
+
%w[credential-manager]],
|
|
59
|
+
"gcloud" => [%w[print-access-token], %w[print-identity-token]],
|
|
60
|
+
"aws" => [%w[configure get], %w[configure export-credentials], %w[ecr get-login-password],
|
|
61
|
+
%w[sts get-session-token]],
|
|
62
|
+
"az" => [%w[account get-access-token]],
|
|
63
|
+
"heroku" => [%w[auth:token]],
|
|
64
|
+
"npm" => [%w[token]],
|
|
65
|
+
"kubectl" => [%w[config view --raw]],
|
|
66
|
+
"keyring" => [%w[get]]
|
|
67
|
+
}.freeze
|
|
68
|
+
# A credential helper run by itself (docker-credential-osxkeychain get).
|
|
69
|
+
CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
|
|
45
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/
|
|
89
|
+
# This machine's loopback names (.web_text): localhost and its
|
|
90
|
+
# subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
|
|
91
|
+
LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
|
|
92
|
+
/\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
|
|
46
93
|
# An interpreter's script that reads the environment or detaches.
|
|
47
94
|
SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
|
|
48
95
|
SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
|
|
49
96
|
# Programs that read a folder's whole tree: given home (or any folder
|
|
50
97
|
# above a denied one) they reach the secrets in it.
|
|
51
|
-
|
|
98
|
+
# A link to such a folder reaches them too (ln, link).
|
|
99
|
+
SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto ln link].freeze
|
|
100
|
+
# Programs that make a link (and cp's options that do).
|
|
101
|
+
LINKERS = %w[ln link mklink].freeze
|
|
102
|
+
CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
|
|
103
|
+
# An interpreter's script that makes a link.
|
|
104
|
+
SCRIPT_LINK_TEXT = /symlink|\blink\(|\bln\s|\bmklink\b|FileUtils\.ln|hardlink/i
|
|
52
105
|
# git settings that make git run a command (or show one), and git
|
|
53
106
|
# config's scopes beyond the repository.
|
|
54
107
|
GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
|
|
55
108
|
GIT_WIDE_SCOPES = %w[--global --system].freeze
|
|
56
109
|
GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
110
|
+
# Devices a command may name in the project's stead.
|
|
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
|
+
|
|
115
|
+
# A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
|
|
116
|
+
# or an absolute path, at the text's start or after a space, quote,
|
|
117
|
+
# bracket, = or comma.
|
|
118
|
+
SCRIPT_PATH = %r{(?:\A|(?<=[\s'"(\[=,]))(?:~|\.\.|/[\w.~-])[^\s'"`;,()\]]*}
|
|
119
|
+
# A $(…) that is only a message's heredoc (git commit -m "$(cat <<EOF …)"),
|
|
120
|
+
# its body cut by ShellLex.code_text.
|
|
121
|
+
MESSAGE_SUBST = /\$\(cat <<-?(['"]?)(\w+)\1\n\2\n\)/
|
|
122
|
+
# Assignments whose value is a search list, not a path.
|
|
123
|
+
LIST_VARIABLES = %w[PATH].freeze
|
|
62
124
|
GLOB = /[*?\[\]{}]/
|
|
125
|
+
SHELL_BRACKETS = %w[[ [[ ]] { } ]].freeze
|
|
63
126
|
# How many matches of a glob are checked against the denylist.
|
|
64
127
|
GLOB_LIMIT = 500
|
|
65
128
|
# How deep scripts handed to a shell are read; deeper is UNREADABLE.
|
|
66
129
|
MAX_DEPTH = 3
|
|
67
130
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
# Whether a pair turn may write +path+ (absolute) without asking: it
|
|
132
|
+
# lands in the project (Outside.inside?) or in a tmp dir the project
|
|
133
|
+
# isn't in (Outside.tmp_write?), every way it resolves. No project
|
|
134
|
+
# (+root+ blank): the tmp dirs only.
|
|
135
|
+
def self.writable?(path, root:, cwd: nil)
|
|
136
|
+
root = root.to_s
|
|
137
|
+
return Outside.tmp_write?(path, root: cwd || Dir.pwd) if root.empty?
|
|
138
|
+
|
|
139
|
+
Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Whether +path+ is in a .git/hooks folder, as given or as it resolves
|
|
143
|
+
# (ShellPaths::HOOKS_PART): git runs what is there.
|
|
144
|
+
def self.git_hook?(path)
|
|
145
|
+
path.to_s.match?(ShellPaths::HOOKS_PART) ||
|
|
146
|
+
(path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A command's text naming chi web's endpoint on this machine: a
|
|
150
|
+
# loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
|
|
151
|
+
# +ports+, anywhere in it (a URL's host:port, nc's host port, a
|
|
152
|
+
# script's ("host", port), /dev/tcp/host/port, curl's --resolve); or
|
|
153
|
+
# 0:port. nil without a port.
|
|
154
|
+
# @return [Regexp, nil]
|
|
155
|
+
def self.web_text(ports, hostnames: [])
|
|
156
|
+
return nil if ports.empty?
|
|
157
|
+
|
|
158
|
+
hosts = (LOOPBACK_HOSTS.map(&:source) + hostnames.map { |name| Regexp.escape(name) }).join("|")
|
|
159
|
+
port = ports.map { |number| Regexp.escape(number.to_s) }.join("|")
|
|
160
|
+
Regexp.new("\\A(?=[\\s\\S]*?(?<![\\w.-])(?:#{hosts})(?![\\w-]))(?=[\\s\\S]*?(?<![\\w.])(?:#{port})(?!\\d))|" \
|
|
161
|
+
"(?<![\\w.:-])0:(?:#{port})(?!\\d)", Regexp::IGNORECASE)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# This machine's own names (Socket.gethostname, with and without
|
|
165
|
+
# its domain and with .local), lowercased.
|
|
166
|
+
def self.hostnames
|
|
167
|
+
require "socket"
|
|
168
|
+
name = Socket.gethostname.to_s.downcase
|
|
169
|
+
short = name.split(".").first.to_s
|
|
170
|
+
[name, short, "#{short}.local"].reject { |host| host.empty? || host == ".local" }.uniq
|
|
171
|
+
rescue StandardError
|
|
172
|
+
[]
|
|
73
173
|
end
|
|
74
174
|
|
|
75
175
|
# @param paths [ReviewPaths] the denylist
|
|
76
|
-
# @param root [String, nil] the project root (outside writes
|
|
77
|
-
|
|
78
|
-
|
|
176
|
+
# @param root [String, nil] the project root (outside writes; none:
|
|
177
|
+
# every command but a read-only one writes outside)
|
|
178
|
+
# @param web [Regexp, nil] chi web's endpoint in a command's text
|
|
179
|
+
# (.web_text)
|
|
180
|
+
def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, web: nil)
|
|
181
|
+
@web = web
|
|
79
182
|
@text = text.to_s
|
|
80
183
|
@cwd = cwd
|
|
81
184
|
@paths = paths
|
|
@@ -93,10 +196,28 @@ module Samagotchi
|
|
|
93
196
|
@refusal = PairBoundary::UNREADABLE
|
|
94
197
|
end
|
|
95
198
|
|
|
96
|
-
# Whether it
|
|
199
|
+
# Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
|
|
200
|
+
# path through a link it makes now is judged before the link exists,
|
|
201
|
+
# so the owner approves it (asked about; a link to a folder above a
|
|
202
|
+
# secret is refused: SWEEPERS). True for one it can't read.
|
|
203
|
+
def links?
|
|
204
|
+
return @links if defined?(@links)
|
|
205
|
+
|
|
206
|
+
@links = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
|
|
207
|
+
words.is_a?(Array) && link_command?(words)
|
|
208
|
+
end
|
|
209
|
+
rescue StandardError
|
|
210
|
+
@links = true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether it may write outside the project (asked about, never
|
|
214
|
+
# denied); true for one it can't read.
|
|
97
215
|
def outside_writes?
|
|
98
|
-
|
|
99
|
-
|
|
216
|
+
return @outside_writes if defined?(@outside_writes)
|
|
217
|
+
|
|
218
|
+
@outside_writes = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
@outside_writes = true
|
|
100
221
|
end
|
|
101
222
|
|
|
102
223
|
private
|
|
@@ -110,14 +231,14 @@ module Samagotchi
|
|
|
110
231
|
return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
|
|
111
232
|
return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
|
|
112
233
|
return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
|
|
234
|
+
return PairBoundary::CHI_WEB if @web && code.match?(@web)
|
|
113
235
|
return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
|
|
114
236
|
|
|
115
|
-
@
|
|
237
|
+
@read_only = ReadOnlyShell.read_only?(@text)
|
|
116
238
|
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
117
239
|
ShellLex.simple_commands(tokens).each do |words|
|
|
118
240
|
next walk.step(words) if words.is_a?(Symbol)
|
|
119
241
|
|
|
120
|
-
@targets.concat(write_targets(words, walk))
|
|
121
242
|
reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
|
|
122
243
|
return reason if reason
|
|
123
244
|
end
|
|
@@ -127,19 +248,53 @@ module Samagotchi
|
|
|
127
248
|
# A simple command's own refusal, by its program and arguments.
|
|
128
249
|
def words_refusal(words)
|
|
129
250
|
at = program_at(words)
|
|
251
|
+
return PairBoundary::CHI if chi_in_values?(words)
|
|
252
|
+
return PairBoundary::VERB if at && unplain_program?(words[at])
|
|
253
|
+
|
|
130
254
|
program = at && File.basename(words[at])
|
|
131
255
|
args = at ? words[(at + 1)..] : []
|
|
132
256
|
return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
|
|
133
|
-
return PairBoundary::CHI if chi_in_values?(words)
|
|
134
257
|
|
|
135
258
|
upto = at ? words[0..at] : words
|
|
136
259
|
return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
|
|
137
260
|
return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
|
|
138
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)
|
|
139
264
|
|
|
140
265
|
nil
|
|
141
266
|
end
|
|
142
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
|
+
|
|
290
|
+
# A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
|
|
291
|
+
# ${e}i); the test brackets and braces are words of their own.
|
|
292
|
+
def unplain_program?(word)
|
|
293
|
+
return false if SHELL_BRACKETS.include?(word)
|
|
294
|
+
|
|
295
|
+
word.include?("$") || word.include?(ShellLex::SUBST) || word.match?(GLOB)
|
|
296
|
+
end
|
|
297
|
+
|
|
143
298
|
# chi in an assignment's value (FOO=chi, alias x='chi …') or in the
|
|
144
299
|
# word after an option that takes code or a command (-exec, -c, -e).
|
|
145
300
|
def chi_in_values?(words)
|
|
@@ -158,8 +313,7 @@ module Samagotchi
|
|
|
158
313
|
program = File.basename(words[at])
|
|
159
314
|
scripts(words, at).each do |script|
|
|
160
315
|
reason = if SHELLS.include?(program) || program == "eval"
|
|
161
|
-
|
|
162
|
-
tmp_roots: @tmp_roots).refusal
|
|
316
|
+
nested(script).refusal
|
|
163
317
|
elsif INTERPRETERS.include?(program)
|
|
164
318
|
script_text_refusal(script)
|
|
165
319
|
end
|
|
@@ -190,6 +344,8 @@ module Samagotchi
|
|
|
190
344
|
return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
|
|
191
345
|
return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
|
|
192
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)
|
|
193
349
|
|
|
194
350
|
nil
|
|
195
351
|
end
|
|
@@ -218,21 +374,32 @@ module Samagotchi
|
|
|
218
374
|
(program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
|
|
219
375
|
end
|
|
220
376
|
|
|
221
|
-
# printenv, security
|
|
222
|
-
#
|
|
377
|
+
# printenv, security, op, a credential printer (CREDENTIAL_PRINTERS)
|
|
378
|
+
# or helper; set, export, declare and typeset that list (no
|
|
379
|
+
# assignment); launchctl getenv/export; ps with a BSD e (ps e, eww) or
|
|
380
|
+
# -E (ps -E, -axE).
|
|
223
381
|
def env_reader?(program, args)
|
|
224
|
-
return true if ENV_READERS.include?(program)
|
|
382
|
+
return true if ENV_READERS.include?(program) || program.to_s.match?(CREDENTIAL_HELPER)
|
|
383
|
+
return true if credential_printer?(program, args)
|
|
225
384
|
|
|
226
385
|
listing = args.none? { |arg| !arg.start_with?("-", "+") }
|
|
227
386
|
case program
|
|
228
387
|
when "set" then args.empty?
|
|
229
388
|
when "export", "declare", "typeset" then listing
|
|
230
389
|
when "launchctl" then %w[getenv export].include?(args.first)
|
|
231
|
-
when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z/) }
|
|
390
|
+
when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z|\A-[a-zA-Z]*E[a-zA-Z]*\z/) }
|
|
232
391
|
else false
|
|
233
392
|
end
|
|
234
393
|
end
|
|
235
394
|
|
|
395
|
+
# Whether +args+ hold one of +program+'s CREDENTIAL_PRINTERS words, in
|
|
396
|
+
# order and next to each other (git -C x credential fill).
|
|
397
|
+
def credential_printer?(program, args)
|
|
398
|
+
CREDENTIAL_PRINTERS.fetch(program, []).any? do |words|
|
|
399
|
+
args.each_cons(words.size).any?(words)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
236
403
|
# env with no command after it prints the environment.
|
|
237
404
|
def bare_env?(words)
|
|
238
405
|
assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
|
|
@@ -279,6 +446,8 @@ module Samagotchi
|
|
|
279
446
|
def paths_refusal(words, resolved)
|
|
280
447
|
program_word = (at = program_at(words)) && File.basename(words[at])
|
|
281
448
|
resolved.each do |word, path|
|
|
449
|
+
return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
|
|
450
|
+
|
|
282
451
|
if path
|
|
283
452
|
return PairBoundary::SECRET if @paths.secret_refusal([path])
|
|
284
453
|
return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
|
|
@@ -293,12 +462,16 @@ module Samagotchi
|
|
|
293
462
|
|
|
294
463
|
# A word Walk can't resolve: a glob checked by what it matches and by
|
|
295
464
|
# its fixed part, a $var by its fixed part (the text before the first
|
|
296
|
-
# glob character or $, with
|
|
297
|
-
#
|
|
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.
|
|
298
471
|
def unresolved_secret?(word)
|
|
299
472
|
return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
|
|
300
473
|
|
|
301
|
-
expanded =
|
|
474
|
+
expanded = PathResolve.expand_head(word, home: @home, env: ENV)
|
|
302
475
|
fixed = expanded[/\A[^*?\[\]{}$]*/]
|
|
303
476
|
dir = @cwd || Dir.pwd
|
|
304
477
|
if !expanded.include?("$") && expanded.match?(GLOB)
|
|
@@ -307,59 +480,141 @@ module Samagotchi
|
|
|
307
480
|
end
|
|
308
481
|
return false if fixed.empty?
|
|
309
482
|
|
|
310
|
-
|
|
483
|
+
prefix = File.expand_path(fixed, dir)
|
|
484
|
+
word.match?(TMPDIR_WORD) ? inside_denied?(prefix) : leads_into_denied?(prefix)
|
|
311
485
|
end
|
|
312
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
|
+
|
|
313
490
|
# Whether a path's text is the start of a denied root's (its parent,
|
|
314
491
|
# or part of its name: ~/.ss) or inside one.
|
|
315
492
|
def leads_into_denied?(prefix)
|
|
316
|
-
@paths.denied_roots.any? { |root| root.start_with?(prefix) ||
|
|
493
|
+
@paths.denied_roots.any? { |root| root.start_with?(prefix) || PathResolve.under?(prefix, root) }
|
|
317
494
|
end
|
|
318
495
|
|
|
319
496
|
# Whether +path+ is a denied root's folder or one above it.
|
|
320
497
|
def above_denied?(path)
|
|
321
|
-
real
|
|
322
|
-
@paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
|
|
498
|
+
PathResolve.reals(path).any? { |real| @paths.denied_roots.any? { |root| PathResolve.under?(root, real) } }
|
|
323
499
|
end
|
|
324
500
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
words.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
501
|
+
def link_command?(words)
|
|
502
|
+
at = program_at(words)
|
|
503
|
+
program = at && File.basename(words[at])
|
|
504
|
+
return true if LINKERS.include?(program)
|
|
505
|
+
return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
|
|
506
|
+
return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
|
|
507
|
+
|
|
508
|
+
runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
|
|
509
|
+
name = File.basename(words[runner])
|
|
510
|
+
scripts(words, runner).any? do |script|
|
|
511
|
+
SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
|
|
335
512
|
end
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
|
|
516
|
+
depth: @depth + 1, web: @web)
|
|
517
|
+
|
|
518
|
+
# Whether it only reads but for writes (a > or >> target, cp's
|
|
519
|
+
# destination, tee's files) into the project or a tmp dir.
|
|
520
|
+
def writes_inside?
|
|
521
|
+
ReadOnlyShell.read_only_but_writes?(@text, cwd: @cwd, home: @home, env: ENV) { |path| writable?(path) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Whether it stays inside the project: every simple command runs in
|
|
525
|
+
# it, and every path it names lands in it or a tmp dir, a script it
|
|
526
|
+
# hands on included (a shell's read as a command, another
|
|
527
|
+
# interpreter's paths by its text). The program's own word is not a
|
|
528
|
+
# path it writes; a word it can't resolve ($var, a $(…) other than a
|
|
529
|
+
# message's heredoc, a dir it can't tell) is outside. A script file's
|
|
530
|
+
# body (ruby x.rb, make) is not read.
|
|
531
|
+
def stays_inside?
|
|
532
|
+
return false if @root.empty? || @depth > MAX_DEPTH
|
|
533
|
+
|
|
534
|
+
tokens = ShellLex.lex(@text)
|
|
535
|
+
return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
|
|
536
|
+
|
|
537
|
+
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
538
|
+
ShellLex.simple_commands(tokens).all? do |words|
|
|
539
|
+
next walk.step(words) if words.is_a?(Symbol)
|
|
540
|
+
|
|
541
|
+
walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
|
|
542
|
+
scripts_inside?(words).tap { walk.step(words) }
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Whether every $(…) is a message's heredoc (its body cut as data).
|
|
547
|
+
def substs_messages_only?
|
|
548
|
+
rest = ShellLex.code_text(@text, keep_written: false).gsub(MESSAGE_SUBST, "")
|
|
549
|
+
!rest.include?("$(") && !rest.include?("`")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Whether each word of a simple command but the program (and the
|
|
553
|
+
# wrappers before it) lands in the project or a tmp dir.
|
|
554
|
+
def words_inside?(words, walk)
|
|
555
|
+
at = program_at(words)
|
|
556
|
+
# xargs runs its program on paths it reads from its input
|
|
557
|
+
return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
|
|
558
|
+
|
|
559
|
+
words.each_with_index.all? do |word, i|
|
|
560
|
+
next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
|
|
561
|
+
next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
|
|
562
|
+
|
|
563
|
+
word_inside?(walk, word)
|
|
347
564
|
end
|
|
348
|
-
targets
|
|
349
565
|
end
|
|
350
566
|
|
|
351
|
-
def
|
|
352
|
-
return true if
|
|
353
|
-
return
|
|
567
|
+
def word_inside?(walk, word)
|
|
568
|
+
return true if data?(word)
|
|
569
|
+
return substs_messages_only? if word.include?(ShellLex::SUBST)
|
|
570
|
+
|
|
571
|
+
candidate = walk.candidate(word) or return true
|
|
572
|
+
return text_inside?(walk, candidate) if candidate.match?(ShellPaths::SCRIPT)
|
|
573
|
+
return glob_inside?(walk, candidate) if candidate.match?(GLOB) && !candidate.include?("$")
|
|
574
|
+
|
|
575
|
+
paths = walk.paths(candidate)
|
|
576
|
+
!paths.empty? && paths.all? { |path| DEVICES.include?(path) || writable?(path) }
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# A word with spaces or quotes is text (a message, a pattern) unless
|
|
580
|
+
# it starts like a path.
|
|
581
|
+
def text_inside?(walk, text)
|
|
582
|
+
return true unless text.match?(%r{\A(?:~|\.\.|/)})
|
|
583
|
+
|
|
584
|
+
expanded = expand_tilde(text)
|
|
585
|
+
return false if expanded.start_with?("~")
|
|
586
|
+
|
|
587
|
+
walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
|
|
588
|
+
end
|
|
354
589
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
590
|
+
# A glob by its fixed part (the text before its first glob character).
|
|
591
|
+
def glob_inside?(walk, word)
|
|
592
|
+
fixed = word[/\A[^*?\[\]{}]*/]
|
|
593
|
+
return walk.dirs.all? { |dir| dir && writable?(dir) } if fixed.empty?
|
|
358
594
|
|
|
359
|
-
|
|
595
|
+
paths = walk.paths(fixed)
|
|
596
|
+
!paths.empty? && paths.all? { |path| writable?(path) }
|
|
360
597
|
end
|
|
361
598
|
|
|
362
|
-
|
|
599
|
+
# The scripts a simple command hands on (#scripts): a shell's as a
|
|
600
|
+
# command of its own, another interpreter's by the paths in its text.
|
|
601
|
+
def scripts_inside?(words)
|
|
602
|
+
limit = program_at(words) || (words.size - 1)
|
|
603
|
+
at = (0..limit).find { |i| runner?(words[i]) } or return true
|
|
604
|
+
program = File.basename(words[at])
|
|
605
|
+
scripts(words, at).all? do |script|
|
|
606
|
+
if SHELLS.include?(program) || program == "eval"
|
|
607
|
+
!nested(script).outside_writes?
|
|
608
|
+
else
|
|
609
|
+
script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(expand_tilde(path), @cwd || Dir.pwd)) }
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
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)
|
|
363
618
|
end
|
|
364
619
|
end
|
|
365
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,22 +82,18 @@ module Samagotchi
|
|
|
80
82
|
|
|
81
83
|
dirs = chi_dirs
|
|
82
84
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
83
|
-
|
|
85
|
+
reals = paths.flat_map { |path| PathResolve.reals(path) }
|
|
86
|
+
return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
|
|
84
87
|
|
|
85
88
|
command = fetch(facts, :command).to_s
|
|
86
89
|
return false if command.empty?
|
|
87
90
|
|
|
88
|
-
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)) }
|
|
89
92
|
end
|
|
90
93
|
|
|
91
|
-
# chi's config dir, the hooks dir, the approval store's dir
|
|
92
|
-
# installed bundles' dir.
|
|
93
|
-
def chi_dirs
|
|
94
|
-
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
95
|
-
bundles = begin MemoryPaths.bundles_dir rescue nil end
|
|
96
|
-
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
|
|
97
|
-
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
98
|
-
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
|
|
99
97
|
|
|
100
98
|
# An approval: its kind says so, or it carries approval facts.
|
|
101
99
|
def approval?(pending)
|