samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +7 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "shell_lex"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Guardrails
|
|
8
|
+
# Whether a shell command only reads: every simple command in it (split
|
|
9
|
+
# at ;, &&, ||, |, newlines and ( … )) starts with an allowlisted verb,
|
|
10
|
+
# gives it no option that writes or runs something, and redirects
|
|
11
|
+
# nothing but to /dev/null or another fd. A rule with skip_read_only
|
|
12
|
+
# lets such a command through.
|
|
13
|
+
#
|
|
14
|
+
# It errs towards "not read-only": anything it can't tell is not.
|
|
15
|
+
# $(…) or backticks, an env assignment or prefix (env, sudo, xargs,
|
|
16
|
+
# sh -c, eval, timeout, …), a background &, a $ expansion other than
|
|
17
|
+
# $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
|
|
18
|
+
# expansion, a < or << redirection, a path to a verb (/bin/ls), and
|
|
19
|
+
# less/more/vim (they run commands) are all not read-only.
|
|
20
|
+
module ReadOnlyShell
|
|
21
|
+
# Operators that join read-only commands (no &, no ;;).
|
|
22
|
+
JOINS = ["&&", "||", ";", "|", "|&", "\n", "(", ")"].freeze
|
|
23
|
+
# A $ expansion allowed at a word's start (the user's own dirs);
|
|
24
|
+
# nothing after it may expand again.
|
|
25
|
+
SAFE_EXPANSION = %r{\A\$(?:\{(?:HOME|XDG_\w+|TMPDIR|PWD)\}|(?:HOME|XDG_[A-Z_]+|TMPDIR|PWD)\b)(?=/|\z)[^${}]*\z}
|
|
26
|
+
# fd dups and closes: 2>&1, >&2, 1>&-.
|
|
27
|
+
FD_DUP = /\A\d*>&(?:\d+|-)\z/
|
|
28
|
+
# A redirection to /dev/null in one word, or its operator alone (the
|
|
29
|
+
# target follows).
|
|
30
|
+
NULL_REDIRECT = %r{\A\d*>>?/dev/null\z}
|
|
31
|
+
BARE_REDIRECT = /\A\d*>>?\z/
|
|
32
|
+
|
|
33
|
+
# Verbs that only read whatever their options: → nil. The others
|
|
34
|
+
# check their arguments.
|
|
35
|
+
PLAIN = %w[ls cat head tail wc stat du pwd echo printf basename dirname realpath readlink cut tr nl diff cmp
|
|
36
|
+
grep egrep fgrep true false cd pushd popd which].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# @param command [String, nil]
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def read_only?(command)
|
|
43
|
+
text = command.to_s
|
|
44
|
+
return false if text.strip.empty?
|
|
45
|
+
|
|
46
|
+
commands = split(ShellLex.lex(text)) or return false
|
|
47
|
+
!commands.empty? && commands.all? { |words| simple_read_only?(words) }
|
|
48
|
+
rescue ArgumentError, EncodingError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The simple commands as [text, specials] word lists; nil when an
|
|
53
|
+
# operator isn't one read-only commands may be joined with.
|
|
54
|
+
def split(tokens)
|
|
55
|
+
commands = [[]]
|
|
56
|
+
tokens.each do |kind, value, specials|
|
|
57
|
+
if kind == :op
|
|
58
|
+
return nil unless JOINS.include?(value)
|
|
59
|
+
|
|
60
|
+
commands << []
|
|
61
|
+
else
|
|
62
|
+
commands.last << [value, specials]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
commands.reject(&:empty?)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def simple_read_only?(words)
|
|
69
|
+
return false if words.any? { |text, specials| text.include?(ShellLex::SUBST) || !expansion_ok?(text, specials) }
|
|
70
|
+
|
|
71
|
+
args = strip_redirects(words) or return false
|
|
72
|
+
verb = args.shift
|
|
73
|
+
return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
|
|
74
|
+
return true if PLAIN.include?(verb)
|
|
75
|
+
|
|
76
|
+
check = CHECKS[verb] or return false
|
|
77
|
+
check.call(args)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A word may carry a $ only as SAFE_EXPANSION, and no brace expansion.
|
|
81
|
+
def expansion_ok?(text, specials)
|
|
82
|
+
return text.match?(SAFE_EXPANSION) if specials.include?("$")
|
|
83
|
+
|
|
84
|
+
!specials.include?("{") && !specials.include?("}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The words with their redirections taken out; nil when one isn't an
|
|
88
|
+
# fd dup or to /dev/null.
|
|
89
|
+
def strip_redirects(words)
|
|
90
|
+
out = []
|
|
91
|
+
pending = false
|
|
92
|
+
words.each do |text, specials|
|
|
93
|
+
if pending
|
|
94
|
+
return nil unless text == "/dev/null"
|
|
95
|
+
|
|
96
|
+
pending = false
|
|
97
|
+
elsif specials.include?("<")
|
|
98
|
+
return nil
|
|
99
|
+
elsif specials.include?(">")
|
|
100
|
+
next if text.match?(FD_DUP) || text.match?(NULL_REDIRECT)
|
|
101
|
+
return nil unless text.match?(BARE_REDIRECT)
|
|
102
|
+
|
|
103
|
+
pending = true
|
|
104
|
+
else
|
|
105
|
+
out << text
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
pending ? nil : out
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A short option group ("-rf") that holds +char+.
|
|
112
|
+
def short_has?(arg, char) = arg.match?(/\A-[^-]/) && arg.include?(char)
|
|
113
|
+
|
|
114
|
+
def rg(args) = args.none? { |a| a.start_with?("--pre") }
|
|
115
|
+
|
|
116
|
+
FIND_ACTIONS = %w[-delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls].freeze
|
|
117
|
+
|
|
118
|
+
def find(args) = args.none? { |a| FIND_ACTIONS.any? { |action| a.start_with?(action) } }
|
|
119
|
+
|
|
120
|
+
# tree -o FILE writes, -R writes 00Tree.html files.
|
|
121
|
+
def tree(args) = args.none? { |a| a.start_with?("--output") || short_has?(a, "o") || short_has?(a, "R") }
|
|
122
|
+
|
|
123
|
+
# file -C compiles a magic file.
|
|
124
|
+
def file(args) = args.none? { |a| a.start_with?("--compile") || short_has?(a, "C") }
|
|
125
|
+
|
|
126
|
+
def sort(args)
|
|
127
|
+
args.none? { |a| a.start_with?("--output", "--compress-program") || short_has?(a, "o") }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# uniq IN OUT writes OUT.
|
|
131
|
+
def uniq(args) = args.reject { |a| a.start_with?("-") }.size <= 1
|
|
132
|
+
|
|
133
|
+
SED_FLAGS = /\A-[nErsuz]+\z/
|
|
134
|
+
SED_LONG = %w[--quiet --silent --regexp-extended --separate --unbuffered --null-data --posix --debug
|
|
135
|
+
--sandbox].freeze
|
|
136
|
+
|
|
137
|
+
# sed without -i/-f, whose scripts only print, delete, substitute
|
|
138
|
+
# (no w or e flag) and the like (SedScript). GNU sed reads options
|
|
139
|
+
# after the file names too (sed 1p f -i), so every word is checked.
|
|
140
|
+
def sed(args)
|
|
141
|
+
scripts = []
|
|
142
|
+
positional = []
|
|
143
|
+
rest = args.dup
|
|
144
|
+
until rest.empty?
|
|
145
|
+
arg = rest.shift
|
|
146
|
+
if !arg.start_with?("-") || arg == "-" then positional << arg
|
|
147
|
+
elsif arg == "--" then positional.concat(rest.shift(rest.size))
|
|
148
|
+
elsif arg == "-e" || arg.match?(/\A-[nErsuz]*e\z/) then scripts << rest.shift.to_s
|
|
149
|
+
elsif (m = arg.match(/\A-[nErsuz]*e(.+)\z/m)) then scripts << m[1]
|
|
150
|
+
elsif (m = arg.match(/\A--expression=(.*)\z/m)) then scripts << m[1]
|
|
151
|
+
elsif arg.match?(SED_FLAGS) || SED_LONG.include?(arg) then next
|
|
152
|
+
else return false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
if scripts.empty?
|
|
156
|
+
return false if positional.empty?
|
|
157
|
+
|
|
158
|
+
scripts << positional.first
|
|
159
|
+
end
|
|
160
|
+
scripts.all? { |script| SedScript.new(script).read_only? }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# awk with no program file or library, whose program has no output
|
|
164
|
+
# redirection, pipe, getline, system() or @include.
|
|
165
|
+
def awk(args)
|
|
166
|
+
rest = args.dup
|
|
167
|
+
program = nil
|
|
168
|
+
until rest.empty?
|
|
169
|
+
arg = rest.shift
|
|
170
|
+
if arg == "-F" || arg == "-v" then rest.shift
|
|
171
|
+
elsif arg.match?(/\A-[Fv]./m) then next
|
|
172
|
+
elsif arg == "--" then program = rest.shift
|
|
173
|
+
elsif arg.start_with?("-") then return false
|
|
174
|
+
else program = arg
|
|
175
|
+
end
|
|
176
|
+
break if program
|
|
177
|
+
end
|
|
178
|
+
return false if program.nil?
|
|
179
|
+
|
|
180
|
+
!program.match?(/>(?!=)|\||\bsystem\b|\bgetline\b|\bclose\b|@/)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
GIT_GLOBALS = %w[--no-pager -P --no-optional-locks].freeze
|
|
184
|
+
GIT_READS = %w[log show diff status rev-parse rev-list ls-files ls-tree cat-file describe shortlog blame grep
|
|
185
|
+
merge-base name-rev show-ref count-objects whatchanged].freeze
|
|
186
|
+
# Options that write a file or run a program from a read.
|
|
187
|
+
GIT_DENY = ["--ext-diff", "--output", "--open-files-in-pager", "-O", "--textconv"].freeze
|
|
188
|
+
GIT_BRANCH_LIST = %w[-a -r -v -vv --all --remotes --verbose --list -l --show-current --merged --no-merged
|
|
189
|
+
--contains --no-contains --points-at --color --no-color --column --no-column
|
|
190
|
+
--ignore-case -i].freeze
|
|
191
|
+
# These make git branch list (the words after them are patterns).
|
|
192
|
+
GIT_BRANCH_LISTING = %w[--list -l --merged --no-merged --contains --no-contains --points-at].freeze
|
|
193
|
+
|
|
194
|
+
# Read-only git: -C and a few harmless globals, then a reading
|
|
195
|
+
# subcommand, without an option that writes or runs something.
|
|
196
|
+
def git(args)
|
|
197
|
+
rest = args.dup
|
|
198
|
+
while (arg = rest.first)&.start_with?("-")
|
|
199
|
+
rest.shift
|
|
200
|
+
if arg == "-C" then rest.shift
|
|
201
|
+
elsif !GIT_GLOBALS.include?(arg) then return false
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
sub = rest.shift or return false
|
|
205
|
+
return false if rest.any? { |a| GIT_DENY.any? { |deny| a.start_with?(deny) } }
|
|
206
|
+
|
|
207
|
+
case sub
|
|
208
|
+
when "grep" then rest.none? { |a| short_has?(a, "O") }
|
|
209
|
+
when *GIT_READS then true
|
|
210
|
+
when "branch" then git_branch_list?(rest)
|
|
211
|
+
when "stash" then %w[list show].include?(rest.first)
|
|
212
|
+
when "worktree" then rest.first == "list"
|
|
213
|
+
when "remote" then rest.empty? || rest.all? { |a| %w[-v --verbose].include?(a) } || rest.first == "get-url"
|
|
214
|
+
when "tag" then rest.empty? || %w[-l --list].include?(rest.first)
|
|
215
|
+
when "reflog" then rest.none? { |a| %w[expire delete drop write].include?(a) }
|
|
216
|
+
else false
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def git_branch_list?(args)
|
|
221
|
+
options = args.select { |a| a.start_with?("-") }
|
|
222
|
+
return false unless options.all? { |a| GIT_BRANCH_LIST.include?(a) || a.match?(/\A--(sort|format|merged|no-merged|contains|no-contains|points-at|color|column)=/) }
|
|
223
|
+
|
|
224
|
+
names = args.size - options.size
|
|
225
|
+
names.zero? || options.any? { |a| GIT_BRANCH_LISTING.any? { |listing| a == listing || a.start_with?("#{listing}=") } }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
CHECKS = {
|
|
229
|
+
"rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
|
|
230
|
+
"sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
|
|
231
|
+
"git" => method(:git)
|
|
232
|
+
}.freeze
|
|
233
|
+
|
|
234
|
+
# A sed script parsed far enough to tell that it only reads: its
|
|
235
|
+
# commands are among the reading ones, s/// has no w or e flag, and
|
|
236
|
+
# nothing it can't parse.
|
|
237
|
+
class SedScript
|
|
238
|
+
# Commands without arguments that only read or print.
|
|
239
|
+
SIMPLE = "pPdDnNgGhHxzlFv="
|
|
240
|
+
# Commands that take a label (to the end of the command).
|
|
241
|
+
LABELED = "btT:"
|
|
242
|
+
|
|
243
|
+
def initialize(text)
|
|
244
|
+
@s = StringScanner.new(text)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def read_only?
|
|
248
|
+
loop do
|
|
249
|
+
skip_space_and_separators
|
|
250
|
+
return true if @s.eos?
|
|
251
|
+
return false unless command
|
|
252
|
+
end
|
|
253
|
+
rescue ArgumentError
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def skip_space_and_separators
|
|
260
|
+
@s.skip(/[\s;]*/)
|
|
261
|
+
@s.skip(/#[^\n]*/) && skip_space_and_separators
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def command
|
|
265
|
+
address && @s.skip(/\s*/)
|
|
266
|
+
address if @s.skip(/,\s*/)
|
|
267
|
+
@s.skip(/!\s*/)
|
|
268
|
+
char = @s.getch or return false
|
|
269
|
+
ok = if SIMPLE.include?(char) then true
|
|
270
|
+
elsif "qQ".include?(char) then @s.skip(/\s*\d*/) || true
|
|
271
|
+
elsif LABELED.include?(char) then @s.skip(/[^;\n}]*/) || true
|
|
272
|
+
elsif char == "{" then return true
|
|
273
|
+
elsif char == "}" then true
|
|
274
|
+
elsif char == "s" then substitute
|
|
275
|
+
elsif char == "y" then transliterate
|
|
276
|
+
else false
|
|
277
|
+
end
|
|
278
|
+
ok && @s.skip(/[ \t]*/) && (@s.eos? || @s.check(/[;\n}#]/))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# A line number (N, N~M, +N, ~N), $, /re/ or \cREc, with I/M flags.
|
|
282
|
+
def address
|
|
283
|
+
return true if @s.skip(/\d+(~\d+)?|\$|[+~]\d+/)
|
|
284
|
+
|
|
285
|
+
if @s.skip(%r{/})
|
|
286
|
+
delimited("/") && (@s.skip(/[IM]*/) || true)
|
|
287
|
+
elsif @s.skip(/\\/)
|
|
288
|
+
delim = @s.getch or return false
|
|
289
|
+
delimited(delim) && (@s.skip(/[IM]*/) || true)
|
|
290
|
+
else
|
|
291
|
+
false
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def substitute
|
|
296
|
+
delim = @s.getch
|
|
297
|
+
return false if delim.nil? || delim.match?(/[\s\\]/)
|
|
298
|
+
|
|
299
|
+
delimited(delim) && delimited(delim) && @s.skip(/[gpiImM\d]*/) && true
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def transliterate
|
|
303
|
+
delim = @s.getch
|
|
304
|
+
return false if delim.nil? || delim.match?(/[\s\\]/)
|
|
305
|
+
|
|
306
|
+
delimited(delim) && delimited(delim)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Up to the next unescaped +delim+ (consumed); false at the end.
|
|
310
|
+
def delimited(delim)
|
|
311
|
+
until @s.eos?
|
|
312
|
+
char = @s.getch
|
|
313
|
+
if char == "\\" then @s.getch
|
|
314
|
+
elsif char == delim then return true
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shell_lex"
|
|
4
|
+
require_relative "shell_paths"
|
|
5
|
+
require_relative "protected_paths"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# Whether a shell command's rm -rf reaches outside the tmp dirs (a
|
|
10
|
+
# rule's rm: outside_tmp). Every rm with both -r and -f in it must
|
|
11
|
+
# name only paths inside a tmp dir (not the tmp dir itself), resolved
|
|
12
|
+
# the way ShellPaths resolves them (cd followed, ~/$HOME/$TMPDIR
|
|
13
|
+
# expanded, symlinks resolved: /tmp/x -> / is outside). A target it
|
|
14
|
+
# can't resolve ($VAR, a glob such as /tmp/*), an rm behind another
|
|
15
|
+
# command (sudo rm, xargs rm) and a tmp dir the session's root is in
|
|
16
|
+
# count as outside.
|
|
17
|
+
module RmTargets
|
|
18
|
+
RECURSIVE = /\A(?:--recursive|-[a-zA-Z]*[rR][a-zA-Z]*)\z/
|
|
19
|
+
FORCE = /\A(?:--force|-[a-zA-Z]*f[a-zA-Z]*)\z/
|
|
20
|
+
|
|
21
|
+
# A word that runs rm: rm, a path to it, or a script that names it
|
|
22
|
+
# (sh -c 'rm -rf ~').
|
|
23
|
+
RM_WORD = ->(word) { word == "rm" || word.end_with?("/rm") || (word.match?(ShellPaths::SCRIPT) && word.match?(/\brm\b/)) }
|
|
24
|
+
# A redirection: the operator alone (its target is the next word) or
|
|
25
|
+
# with its target.
|
|
26
|
+
REDIRECT = /\A\d*(?:&>>?|>>?&?|<)/
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param command [String]
|
|
31
|
+
# @param cwd [String, nil] where the command starts
|
|
32
|
+
# @param tmp_roots [Array<String>] resolved tmp dirs
|
|
33
|
+
# @param session_root [String] the session's repo root (or cwd)
|
|
34
|
+
def outside_tmp?(command, cwd:, tmp_roots:, session_root:, home: Dir.home, env: ENV)
|
|
35
|
+
tokens = ShellLex.lex(command.to_s)
|
|
36
|
+
# rm inside $(…) or backticks: the lexer keeps no body.
|
|
37
|
+
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(/\brm\b/)
|
|
38
|
+
|
|
39
|
+
session = ProtectedPaths.real(session_root.to_s)
|
|
40
|
+
roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
|
|
41
|
+
walk = ShellPaths::Walk.new(cwd, home, env)
|
|
42
|
+
ShellLex.simple_commands(tokens).any? do |words|
|
|
43
|
+
wide = words.is_a?(Array) && words.any?(&RM_WORD) && wide?(words, walk, roots)
|
|
44
|
+
walk.step(words)
|
|
45
|
+
wide
|
|
46
|
+
end
|
|
47
|
+
rescue ArgumentError, EncodingError
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A simple command that runs rm: outside unless it is a plain rm
|
|
52
|
+
# (env assignments before it at most) without -r and -f, or one whose
|
|
53
|
+
# targets are all inside +roots+.
|
|
54
|
+
def wide?(words, walk, roots)
|
|
55
|
+
args = words.drop_while { |w| w.match?(/\A[A-Za-z_]\w*=/) }
|
|
56
|
+
return true unless args.first == "rm"
|
|
57
|
+
|
|
58
|
+
options, targets = split(args.drop(1))
|
|
59
|
+
return false unless options.any? { |o| o.match?(RECURSIVE) } && options.any? { |o| o.match?(FORCE) }
|
|
60
|
+
|
|
61
|
+
targets.empty? || targets.any? do |target|
|
|
62
|
+
path = walk.path(target)
|
|
63
|
+
real = path && ProtectedPaths.real(path)
|
|
64
|
+
real.nil? || roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# rm's options and targets, redirections left out.
|
|
69
|
+
def split(args)
|
|
70
|
+
options = []
|
|
71
|
+
targets = []
|
|
72
|
+
after_dashes = false
|
|
73
|
+
skip = false
|
|
74
|
+
args.each do |arg|
|
|
75
|
+
if skip then skip = false
|
|
76
|
+
elsif arg.match?(REDIRECT) then skip = arg.sub(REDIRECT, "").empty?
|
|
77
|
+
elsif !after_dashes && arg == "--" then after_dashes = true
|
|
78
|
+
elsif !after_dashes && arg.start_with?("-") && arg != "-" then options << arg
|
|
79
|
+
else targets << arg
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
[options, targets]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -20,6 +20,16 @@ module Samagotchi
|
|
|
20
20
|
# or a glob on the bare model name or the model key, or a
|
|
21
21
|
# list of them: the rule only votes for a model that matches
|
|
22
22
|
# (no model matches none; missing = every model)
|
|
23
|
+
# touches: "chi_dirs": a command names a path in chi's config, hooks,
|
|
24
|
+
# approvals or bundles dir or a .git/hooks dir (resolved;
|
|
25
|
+
# a word that can't be resolved falls back to text)
|
|
26
|
+
# rm: "outside_tmp": an rm -rf in a shell command reaches
|
|
27
|
+
# outside the tmp dirs (RmTargets)
|
|
28
|
+
# modes: "strict", "auto" or a list of them: the rule only votes
|
|
29
|
+
# in those guardrails.mode values (missing = every mode;
|
|
30
|
+
# a rule from config.yml without it votes in every mode)
|
|
31
|
+
# skip_read_only: true: a shell command that only reads
|
|
32
|
+
# (ReadOnlyShell) doesn't match
|
|
23
33
|
# verdict ask|deny, reason, scopes (for an ask; default all).
|
|
24
34
|
# `guardrails.disable` switches single rules off (#parse_disable).
|
|
25
35
|
# A rule that doesn't parse raises ParseError: the Engine then denies
|
|
@@ -28,12 +38,15 @@ module Samagotchi
|
|
|
28
38
|
class Rules
|
|
29
39
|
class ParseError < StandardError; end
|
|
30
40
|
|
|
31
|
-
KEYS = %w[id tool command path git models verdict reason scopes].freeze
|
|
32
|
-
MATCH_KEYS = %w[tool command path git].freeze
|
|
41
|
+
KEYS = %w[id tool command path git touches rm models modes skip_read_only verdict reason scopes].freeze
|
|
42
|
+
MATCH_KEYS = %w[tool command path git touches rm].freeze
|
|
33
43
|
VERDICTS = %w[ask deny].freeze
|
|
44
|
+
# guardrails.mode's values.
|
|
45
|
+
MODES = %w[auto strict].freeze
|
|
34
46
|
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
35
47
|
|
|
36
|
-
Rule = Struct.new(:id, :tools, :command, :path, :git, :
|
|
48
|
+
Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :models, :modes, :skip_read_only, :verdict, :reason,
|
|
49
|
+
:scopes, :source, keyword_init: true) do
|
|
37
50
|
def matches?(targets)
|
|
38
51
|
return false unless targets
|
|
39
52
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
@@ -41,10 +54,16 @@ module Samagotchi
|
|
|
41
54
|
return false if command && !(targets.command && command.match?(targets.command))
|
|
42
55
|
return false if path && !path_matches?(targets)
|
|
43
56
|
return false if git && !targets.git_outside_repo?
|
|
57
|
+
return false if touches && !targets.touches_chi?
|
|
58
|
+
return false if rm && !targets.rm_outside_tmp?
|
|
59
|
+
return false if skip_read_only && targets.read_only?
|
|
44
60
|
|
|
45
61
|
true
|
|
46
62
|
end
|
|
47
63
|
|
|
64
|
+
# Whether the rule votes in guardrails.mode +mode+.
|
|
65
|
+
def for_mode?(mode) = modes.nil? || modes.include?(mode.to_s)
|
|
66
|
+
|
|
48
67
|
def tool_matches?(name)
|
|
49
68
|
tools.any? { |tool| Rules.glob?(tool) ? File.fnmatch(tool, name.to_s, File::FNM_EXTGLOB) : tool == name }
|
|
50
69
|
end
|
|
@@ -108,7 +127,10 @@ module Samagotchi
|
|
|
108
127
|
end
|
|
109
128
|
|
|
110
129
|
Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
|
|
111
|
-
path: path_of(raw["path"], label), git: git_of(raw["git"], label),
|
|
130
|
+
path: path_of(raw["path"], label), git: git_of(raw["git"], label),
|
|
131
|
+
touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), models: models_of(raw["models"], label),
|
|
132
|
+
modes: modes_of(raw["modes"], label),
|
|
133
|
+
skip_read_only: flag_of(raw, "skip_read_only", label), verdict: verdict.to_sym,
|
|
112
134
|
reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
|
|
113
135
|
end
|
|
114
136
|
|
|
@@ -147,6 +169,41 @@ module Samagotchi
|
|
|
147
169
|
value
|
|
148
170
|
end
|
|
149
171
|
|
|
172
|
+
# Only "chi_dirs" for now.
|
|
173
|
+
def self.touches_of(value, label)
|
|
174
|
+
return nil if value.nil?
|
|
175
|
+
raise ParseError, "#{label}: touches must be chi_dirs" unless value == "chi_dirs"
|
|
176
|
+
|
|
177
|
+
value
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Only "outside_tmp" for now.
|
|
181
|
+
def self.rm_of(value, label)
|
|
182
|
+
return nil if value.nil?
|
|
183
|
+
raise ParseError, "#{label}: rm must be outside_tmp" unless value == "outside_tmp"
|
|
184
|
+
|
|
185
|
+
value
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.modes_of(value, label)
|
|
189
|
+
return nil if value.nil?
|
|
190
|
+
|
|
191
|
+
names = Array(value)
|
|
192
|
+
unless !names.empty? && names.all? { |n| MODES.include?(n) }
|
|
193
|
+
raise ParseError, "#{label}: modes must be #{MODES.join(" or ")}, or a list of them"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
names
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A true/false key; missing is false.
|
|
200
|
+
def self.flag_of(raw, key, label)
|
|
201
|
+
value = raw.fetch(key, false)
|
|
202
|
+
raise ParseError, "#{label}: #{key} must be true or false" unless [true, false].include?(value)
|
|
203
|
+
|
|
204
|
+
value
|
|
205
|
+
end
|
|
206
|
+
|
|
150
207
|
def self.models_of(value, label)
|
|
151
208
|
return nil if value.nil?
|
|
152
209
|
|
|
@@ -187,14 +244,20 @@ module Samagotchi
|
|
|
187
244
|
# @param rules [Array<Rule>] in order: config first, then bundles by name
|
|
188
245
|
# @param enabled [Boolean] false: no rules, and hooks' asks are dropped
|
|
189
246
|
# @param disable [Array<String>] from #parse_disable: rules that don't vote
|
|
190
|
-
|
|
247
|
+
# @param mode [String] guardrails.mode: a rule whose modes: leave it
|
|
248
|
+
# out doesn't vote
|
|
249
|
+
def initialize(rules = [], enabled: true, disable: [], mode: "auto")
|
|
191
250
|
@rules = rules
|
|
192
251
|
@enabled = enabled
|
|
193
252
|
@disable = disable
|
|
253
|
+
@mode = mode.to_s
|
|
194
254
|
end
|
|
195
255
|
|
|
196
256
|
def enabled? = @enabled
|
|
197
257
|
|
|
258
|
+
# @return [String] guardrails.mode, "auto" or "strict"
|
|
259
|
+
attr_reader :mode
|
|
260
|
+
|
|
198
261
|
def disabled?(rule)
|
|
199
262
|
@disable.any? { |entry| disables?(entry, rule) }
|
|
200
263
|
end
|
|
@@ -210,6 +273,7 @@ module Samagotchi
|
|
|
210
273
|
|
|
211
274
|
@rules.each do |rule|
|
|
212
275
|
next if disabled?(rule)
|
|
276
|
+
next unless rule.for_mode?(@mode)
|
|
213
277
|
next unless rule.matches?(verdict.targets)
|
|
214
278
|
|
|
215
279
|
if rule.verdict == :deny
|