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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. 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, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
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), models: models_of(raw["models"], label), verdict: verdict.to_sym,
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
- def initialize(rules = [], enabled: true, disable: [])
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